HTML interface
Usage guide of ui.webui: the page API, transports, input sharing between the page and the game, settings and defaults, building the page, fragments of presentation modules, events and service, engines and limits.
HTML interface over your game (ui.webui) #
Draw menus, HUD, inventories and dialogs as a web page over the game, written once, with one page API, on every output. Readable offline: gripforge docs ui-webui, or the MCP tool gripforge_docs (topic ui-webui).
What it is for #
ui.webui is the bridge between an HTML / JavaScript page and the game. The page can use any framework (or plain DOM) and is built by a web bundler. The kit pushes the game state to the page, announces the commands the page sends, and shares the pointer and the keyboard between the page and the game. It decides nothing of the game: a command of the page is announced (ui.command) and the kit or rule that answers it owns the decision.
| Output | How the page is drawn |
|---|---|
| Web player, Godot web export | DOM loaded into the play document, above the game canvas |
| Godot, Unity, Unreal desktop | a Chromium-based browser already installed on the machine, driven off-screen; its frames are composed over the game as a texture |
| Native player | the HUD and menus are always ui.webui (fragments and ui.tree), never a second UI |
Turn it on #
gripforge_gamekit_install { project_id: "gkp_…", id: "ui.webui" }
gripforge_gamekit_configure { project_id: "gkp_…", id: "ui.webui", config: { "stateHz": 15, "forwardEvents": ["score.changed"] } }
It requires game.engine. On Godot, Unity and Unreal the game chooses between the engine's native UI and ui.webui; by default one way per game (mixNativeUi: true keeps both, best avoided). The full reference is gripforge kit api ui.webui.
The page #
gf-webui.js (no framework, no dependency) is the same file on every output:
import { createBridge } from './gf-webui.js'
const bridge = createBridge({
onState: (state) => render(state), // full state, pushed by the game when it changes
onEvent: (kind, data) => toast(kind, data), // one-shot events
onOpen: () => bridge.request(), // (re)connected: ask for the full state again
})
bridge.start()
button.onclick = () => bridge.send('heal', { amount: 10 })
The page picks its transport by itself: web (the host defined window.__gfui_send), ipc (injected by the engine into the off-screen page), devws (a WebSocket to the game, the page open in a normal browser) or mock (?mock=1, no game).
Two layout rules make the input sharing exact:
- a transparent background (paint one only under
html.gfui-standalone); - a zero-sized root (
#gfui-root { position: fixed; width: 0; height: 0 }): every piece of UI is positioned against the window and covers only what it paints; nopointer-eventsdeclarations.
Settings and defaults #
| Key | Default | Meaning |
|---|---|---|
page | webui | folder of the built page |
namespace | __gfui | page globals namespace |
transport | auto | auto, osr, devws, web, none |
autoState | true | web: push the game variables to the page |
stateHz | 30 | web: automatic state pushes per second at most (moddable, 1 to 120) |
forwardEvents | [] | web: bus topics forwarded to the page as events |
mount | true | web: load the page into the document |
fragments | [] | fragments declared by the game or a mod (below) |
theme | true | send the game theme (ui.theme, --gf-* variables) to the fragments (moddable) |
mixNativeUi | false | desktop engines: keep the engine's native UI too |
rebuildOnRun | true | engines: rebuild the page on every run or compile |
devUrl | "" | desktop: load this URL (a dev server, hot reload inside the game) |
browserPath | "" | desktop: the browser to drive (or env GF_WEBUI_BROWSER) |
devtoolsPort | 0 | desktop: 0 = a free port picked by the browser; keep 0 so several games can run side by side |
renderScale, everyNthFrame | 1, 1 | desktop: image scale and frame skipping, to trade quality for cost |
devPort | 9800 | Godot devws transport only |
Only stateHz and theme are moddable: page, devUrl, browserPath, transport, mount... say which page, browser or port is used, and a mod must not point the bridge at another page.
Input sharing #
What the page does not claim belongs to the game.
- Keep the page background transparent (a still, fully opaque page is hidden after a few seconds).
- Off-screen, a press goes to the page only where the last frame is not transparent; a release goes to whoever received the press; moves are never consumed.
- The keyboard belongs to the page only while one of its elements has the focus; a clicked button gives it back, a text field keeps it until the player clicks the game.
- On the web, native DOM routing does it all.
- Unity cannot consume an input event: gate your game input with
GfWebUI.GameOwnsPointer/GfWebUI.GameOwnsKeyboard.
Building the page #
ui/build.mjs builds the page with the bundler and copies it where each output serves it; it is incremental (a hash of the sources is stamped in the output). Node.js 18+ is needed on the development machine only; without it the engines keep the last page.
| Output | Page served from |
|---|---|
| Godot | res://<page>/ (add <page>/* to the export's non-resource filter) |
| Unity | Assets/StreamingAssets/<page>/ |
| Unreal | Content/<page>/ (Additional Non-Asset Directories to Package) |
| Web export | <export dir>/<page>/ with node build.mjs --web <export dir> |
Fragments: the web UI of presentation modules #
A presentation module can bring its HUD and menus as a fragment: one file under modules/, ui.mjs exporting React components (the page's React, the module bundles none) and/or templates with data bindings, or a .html template. A module declares it in the web entry of present-modules.json ({ ui, views, vars, shared, emits, layer, origin, sha256 }); the game declares one with the fragments config (one per kit, the last wins).
- Data in: the fragment receives only the keys it lists (
views,vars,shared,theme,themeVars,config,lang,assets,inputs,planned), as objects, with adeltaof what changed. The theme also lands as--gf-*CSS variables (var(--gf-accent, <yours>)). - Events back:
dispatch(event, payload)of a component, ordata-gf-on-clickof a template, sendsui:emit. Accepted only for an event listed inemits; it becomesui.command { cmd, payload, kit }, otherwiseui.fragment.refused. A fragment never feeds game input. - Direct calls (secrets):
call(method, args)reaches a method of the fragment's own kit listed incallsof itsweb/ui.json. It is never an event: what it carries (a password typed in a sign-in form) reaches no bus, mod, trace or log. - Trust: a fragment runs with the trust of the mod scripts of
game.engine.origin: "mod:<id>"obeys the mods policy (mods.enabled,mods.disabled,mods.deny.kits);sha256is checked when given; scripts andon*attributes of a template are dropped. - One way per kit: when a fragment draws a kit's interface, that kit's page views are not mounted beside it, so a HUD is never drawn twice. A refused fragment gives the page view back.
ui.tree: a module that describes its UI as a tree is drawn as DOM by this kit too; both end in the same layer.
Events and service #
| What | Name | Detail |
|---|---|---|
| Emits | ui.command { cmd, payload } | every page command (kit added for a fragment event) |
| Emits | ui.ready { transport } | the page is connected |
| Emits | ui.fragment.refused { kit, event, error } | a fragment event or call was refused |
| Listens | ui.state { data } | merged into the state sent to the page |
| Listens | ui.event { kind, data } | forwarded to the page as an event |
| Service | ui.webui | pushState, patchState, pushEvent, onCommand, receive, state, status, and for fragments declareFragments, feedFragments, setTheme, setFragmentContext, emitFragment, onFragmentEvent, mountFragments |
The kit declares no hook. The state and the outbox are not saved: the game pushes its state again after a load.
Engines and limits #
- The web player and the Godot web export need nothing more than the page.
- Desktop engines need a Chromium-based browser installed on the player's machine. The page is served from the project files (no HTTP server, no fixed port), frames are transported as images, so cost grows with a page that animates:
renderScaleandeveryNthFrametrade quality for cost. - Text is rendered at device scale 1 off-screen. Mobile and consoles are not covered by the off-screen path; Unity WebGL and Unreal HTML5 are not delivered.
When it does not do what you expect #
| Symptom | Cause and fix |
|---|---|
| The page covers the game or eats clicks | an opaque background or a root with a size: keep it transparent and zero-sized |
| Keys do not reach the game | an element of the page keeps the focus: blur it after use |
| A command of the page does nothing | read ui.command on the bus; for a fragment, the event is missing from emits (ui.fragment.refused) |
| The HUD is drawn twice | a native view and a fragment for the same kit: declare the fragment, the view is dropped |
| Two games clash on a port | a fixed devtoolsPort: set it back to 0 |