Tutorial 4: Write the interface by hand (not recommended)
No AI: write a web app by hand in an empty folder, put it in Unreal and connect the two sides; the second half is a reference of each feature, and every section can be read on its own. This is not the recommended way to make an interface: handing it to AI as in Tutorial 1 is much faster.
On this page
- How it works
- Prepare
- Part 1: Create a web app
- Folders
- .gitignore
- package.json
- tsconfig.json
- vite.config.ts
- index.html
- src/bridge/orion-webui.ts
- src/main.ts
- src/model
- src/App.vue
- Install, check, build
- Look at it in a browser
- Part 2: Put it in Unreal
- App Definition properties
- Orion WebUI widget properties
- Show it, set the input mode, give it focus
- Preview in UMG Designer
- Where dist is read from
- Part 3: Messages between the page and Unreal
- The interface at a glance
- State
- Actions
- Unreal calls the page
- Built-in events
- Presentation phases
- Part 4: Rules for writing pages
- Control Ids
- data-orion-* attributes
- Part 5: Input
- Part 6: Sound, text, fonts and images
- Sound
- Text and language
- Fonts
- Images
- Live pictures: Native Surface
- Part 7: WorldUI
- Part 8: Remote websites
- Part 9: Several pages and routes
- Part 10: Security
- Part 11: Performance and configuration
- Hardware-accelerated frame transfer
- Part 12: Debugging
- Part 13: Check and deliver
- Troubleshooting
To let AI write the page, see Tutorial 1. For the preview tool, see Tutorial 2. For the Unreal side in Blueprint and C++, see Tutorial 3.
How it works
The page is shown on a UMG widget, and the game shows through where the page is transparent. Its files come from Content/; no web server and no network are needed.
Unreal and the page exchange only JSON messages. Unreal owns the state: Unreal sends the state to the page to show, the page submits what the player does, and Unreal decides whether to accept it.
- StateRevision
- 0
- Volume
- 80
- Quality
- high
- Culture
Settings
| Widget | Purpose |
|---|---|
| Orion WebUI | Hosts one local app with the game bridge. The subject of this tutorial |
| Orion Browser | Opens a remote website; no bridge |
| Orion InstantScreen | Several interfaces share one resident browser; see Tutorial 3 |
Prepare
- Complete installation and download the dependencies: click the WebUIStudio button on the toolbar and click Download in the OrionBrowser Dependencies window. The plugin never downloads them by itself. In a blank project, also set the CommonUI viewport as described on the installation page.
- Install Node.js 22 or a newer LTS release (npm is included); building the page needs it.
- Put the plugin in the project's
Plugins/. If it is elsewhere, rewrite the relative paths to the plugin in the files below.OrionBrowser - Run the supplied sample
OrionBrowser/once to confirm that the environment works.Showcase/ L_OrionBrowserOverview
Part 1: Create a web app
The example is a settings panel: it shows volume, quality and language, and lets the player change the volume, choose the quality, restore defaults and close it.
Folders
<Project>/ ├─ Plugins/OrionBrowser/ the plugin; its shared runtime is in Content/UI/WebUI/Shared └─ Content/UI/WebUI/SettingsPanel/ your app; the folder name is the AppId ├─ .gitignore ├─ package.json ├─ tsconfig.json ├─ vite.config.ts ├─ index.html ├─ webui-preview.json optional: the view list for WebUIStudio ├─ src/ │ ├─ main.ts │ ├─ App.vue │ ├─ bridge/orion-webui.ts │ └─ model/ │ ├─ settings-panel-state.ts │ └─ settings-panel-preview.ts └─ dist/ build output; running and packaging read it
Three names must be identical: the folder name, orionWebUI.appId in package.json, and AppId of the App Definition. An app of the project is read from Content/, and packaging collects only that folder.
Content/UI/WebUI/SettingsPanel/orionWebUI.appId in package.json"orionWebUI": { "appId": "SettingsPanel", … }AppId of the App Definition in UnrealAppId = SettingsPanelAll three must match character for character: at run time the page is read from Content/UI/WebUI/<AppId>/dist, and packaging collects only that folder.
.gitignore
Create it before installing dependencies. Do not ignore dist.
**/node_modules/
**/.npm/
**/.vite/
**/.cache/
*.tsbuildinfo
npm-debug.log*package.json
{
"private": true,
"name": "settings-panel",
"version": "1.0.0",
"type": "module",
"orionWebUI": { "appId": "SettingsPanel", "interfaceName": "settings-panel", "performanceProfile": "transactional-screen", "lifecyclePolicy": "transactional" },
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"test": "node ../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/scripts/test-webui-app-contract.mjs",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"typescript": "^5.8.0",
"vite": "^6.3.0",
"vue": "^3.5.0",
"vue-tsc": "^2.2.0"
}
}appId: an English name starting with a capital letter, identical to the folder name.interfaceName: lowercase with hyphens, unique in the project. It sets the file names of the build output.performanceProfile/lifecyclePolicy: menus, dialogs and other interactive pages usetransactional-screen/transactional; a HUD that is always shown and takes no input usescontinuous-passive/continuous-passive.
tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "vue": ["./node_modules/vue"] },
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}Do not omit paths.vue: it makes the type check use the vue installed in your app.
vite.config.ts
import path from "node:path";
import { fileURLToPath } from "node:url";
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
import { createOrionWebUIBuildOutput, createOrionWebUIVueOptions } from "../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/vite-output-naming";
const rootDir = path.dirname(fileURLToPath(import.meta.url));
const sharedSourceDir = path.resolve(rootDir, "../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/src");
const appId = "SettingsPanel";
export default defineConfig({
plugins: [
vue(createOrionWebUIVueOptions(rootDir)),
{
name: "orion-webui-manifest",
generateBundle() {
this.emitFile({
type: "asset",
fileName: "orion-webui.manifest.json",
source: JSON.stringify({ appId, entry: "index.html", apiVersion: "1.0.0", localOnly: true }, null, "\t") + "\n",
});
},
},
],
base: "./",
build: {
outDir: path.resolve(rootDir, "dist"),
emptyOutDir: true,
sourcemap: false,
...createOrionWebUIBuildOutput("settings-panel", { performanceProfile: "transactional-screen" }),
},
server: {
host: "127.0.0.1",
port: 5180,
strictPort: false,
fs: { allow: [rootDir, sharedSourceDir] },
},
});Write the two values of createOrionWebUIBuildOutput as string literals that match package.json. Do not add [hash] to file names. Give each app a different server.port.
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Settings Panel</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>Do not add <meta name="color-scheme" content="dark">: the page canvas becomes opaque and the game no longer shows through.
src/bridge/orion-webui.ts
Keep the long paths into the plugin in this one file; the other files import from here.
export type { OrionWebUIApi } from "../../../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/src/bridge/orion-webui";
export { installCommonActionRouter, resolveOrionWebUIForMount } from "../../../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/src/bridge/orion-webui";
export { type OrionWebUILifecycleController, createOrionWebUILifecycle } from "../../../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/src/runtime/orion-webui-lifecycle";
export { createOrionWebUIApp, ensureOrionWebUIFontStylesheet } from "../../../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/src/instant-screen/orion-instant-screen-vue";src/main.ts
import App from "./App.vue";
import { createOrionWebUIApp, ensureOrionWebUIFontStylesheet, resolveOrionWebUIForMount } from "./bridge/orion-webui";
ensureOrionWebUIFontStylesheet("SettingsPanel");
async function mountSettingsPanel(): Promise<void> {
const api = await resolveOrionWebUIForMount();
createOrionWebUIApp(App, { api }).mount("#app");
}
void mountSettingsPanel().catch((error: unknown) => {
console.error("SettingsPanel failed to connect to the Orion WebUI host bridge.", error);
});resolveOrionWebUIForMount() returns the bridge in Unreal and undefined in an ordinary browser and in UMG Designer. Use it to decide whether there is a host; do not poll window.OrionWebUI yourself.
src/model
// src/model/settings-panel-state.ts
export type Quality = "low" | "medium" | "high";
export interface SettingsPanelState {
stateRevision: number;
volume: number;
quality: Quality;
culture: string;
}// src/model/settings-panel-preview.ts
import type { SettingsPanelState } from "./settings-panel-state";
export function createSettingsPanelPreviewState(): SettingsPanelState {
return { stateRevision: 1, volume: 80, quality: "high", culture: "en" };
}Preview data lives in a module of its own and is loaded only when there is no host. The export name must contain both preview and state for WebUIStudio to find it.
src/App.vue
<template>
<main ref="rootElement" class="settings">
<header class="settings__header">
<h1>{{ text.title }}</h1>
<button type="button" data-orion-control-id="settings-close" @click="close">{{ text.close }}</button>
</header>
<label class="settings__row" for="settings-volume">
<span>{{ text.volume }}</span>
<output>{{ volumeDraft }}</output>
</label>
<input
id="settings-volume"
v-model.number="volumeDraft"
type="range"
min="0"
max="100"
step="1"
data-orion-control-id="settings-volume"
@change="setVolume"
/>
<div class="settings__row" role="group" :aria-label="text.quality">
<span>{{ text.quality }}</span>
<button
v-for="option in qualityOptions"
:key="option"
type="button"
data-orion-control-id="settings-quality"
:aria-pressed="state.quality === option"
@click="setQuality(option)"
>{{ text[option] }}</button>
</div>
<footer class="settings__footer">
<button type="button" data-orion-control-id="settings-reset" :disabled="resetting" @click="resetDefaults">{{ text.reset }}</button>
<p role="status">{{ notice }}</p>
</footer>
</main>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { type OrionWebUIApi, type OrionWebUILifecycleController, createOrionWebUILifecycle, installCommonActionRouter } from "./bridge/orion-webui";
import type { Quality, SettingsPanelState } from "./model/settings-panel-state";
const dictionary = {
"en": { title: "Settings", volume: "Volume", quality: "Quality", low: "Low", medium: "Medium", high: "High", reset: "Restore defaults", close: "Close", restored: "Defaults restored." },
"zh-Hans": { title: "设置", volume: "音量", quality: "画质", low: "低", medium: "中", high: "高", reset: "还原默认", close: "关闭", restored: "已还原默认设置。" },
} as const;
const qualityOptions: readonly Quality[] = ["low", "medium", "high"];
const props = defineProps<{ api?: OrionWebUIApi }>();
const rootElement = ref<HTMLElement | null>(null);
const state = reactive<SettingsPanelState>({ stateRevision: 0, volume: 0, quality: "medium", culture: "en" });
const volumeDraft = ref(0);
const resetting = ref(false);
const notice = ref("");
const text = computed(() => dictionary[state.culture === "zh-Hans" ? "zh-Hans" : "en"]);
const releases: Array<() => void> = [];
let lifecycle: OrionWebUILifecycleController | null = null;
let disposed = false;
function applyState(next: SettingsPanelState): void {
if (next.stateRevision <= state.stateRevision) {
return;
}
Object.assign(state, next);
volumeDraft.value = state.volume;
void nextTick(() => props.api?.stateCommitted(state.stateRevision).catch(report));
}
function report(error: unknown): void {
notice.value = error instanceof Error ? error.message : String(error);
}
function setVolume(): void {
props.api?.emit("settingsPanel.setVolume", { volume: volumeDraft.value }).catch(report);
}
function setQuality(quality: Quality): void {
props.api?.emit("settingsPanel.setQuality", { quality }).catch(report);
}
function close(): void {
props.api?.emit("settingsPanel.close", {}).catch(report);
}
async function resetDefaults(): Promise<void> {
if (!props.api || resetting.value) {
return;
}
resetting.value = true;
try {
await props.api.call("settingsPanel.resetDefaults", {});
notice.value = text.value.restored;
} catch (error) {
report(error);
} finally {
resetting.value = false;
}
}
onMounted(async () => {
const root = rootElement.value;
if (!root) {
return;
}
const api = props.api;
if (!api) {
const preview = await import("./model/settings-panel-preview");
applyState(preview.createSettingsPanelPreviewState());
root.dataset.orionPresentation = "active";
return;
}
releases.push(api.on<SettingsPanelState>("ue:settingsPanel.state", applyState));
releases.push(installCommonActionRouter(api, root));
lifecycle = createOrionWebUILifecycle({ api, root });
const bootstrap = await lifecycle.initialize().catch((error: unknown) => {
if (error instanceof Error && error.name === "AbortError") {
return null;
}
throw error;
});
if (!bootstrap || disposed) {
return;
}
await api.emit("settingsPanel.ready", {}).catch(report);
});
onBeforeUnmount(() => {
disposed = true;
lifecycle?.dispose();
lifecycle = null;
for (const release of releases.splice(0)) {
release();
}
});
</script>
<style scoped>
:global(html), :global(body), :global(#app) {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: transparent;
}
.settings {
position: absolute;
top: 50%;
left: 50%;
width: 520px;
padding: 28px;
transform: translate(-50%, -50%);
color: #f2f4f8;
background: rgba(12, 16, 24, 0.86);
border: 1px solid rgba(255, 255, 255, 0.16);
font-family: var(--orion-webui-body-font-family, var(--orion-webui-font-family, sans-serif));
opacity: 0;
pointer-events: none;
}
.settings[data-orion-presentation="entering"],
.settings[data-orion-presentation="active"] {
opacity: 1;
}
.settings[data-orion-presentation="active"] {
pointer-events: auto;
}
.settings__header,
.settings__row,
.settings__footer {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.settings__header h1 {
flex: 1;
margin: 0;
font-size: 28px;
}
.settings__row span {
flex: 1;
}
input[type="range"] {
width: 100%;
margin-bottom: 20px;
}
button {
padding: 8px 16px;
color: inherit;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.24);
font: inherit;
}
button:hover,
button:focus-visible,
button[aria-pressed="true"] {
background: rgba(255, 170, 60, 0.28);
border-color: #ffaa3c;
outline: none;
}
button:disabled {
opacity: 0.4;
}
</style>Key points:
- Listen first, then announce ready: register the state listener with
api.on, create the lifecycle and callinitialize(), and only then sendsettingsPanel.ready. - State replaces state: drop a state whose revision is not newer, and acknowledge with
stateCommittedafter the view has updated. The slider is reset to Unreal's value whenever a state arrives. - Catch the failure of every
emitandcall. - The presentation phase controls the root element's visibility: it starts at
opacity: 0, is visible inenteringandactive, and accepts clicks only inactive. Do not hide the root withdisplay: none.
Install, check, build
Run in the app folder:
npm.cmd install # first time: installs dependencies and creates package-lock.json. Use npm.cmd ci afterwards
npm.cmd run typecheck # type check
npm.cmd test # app contract check
npm.cmd run build # produces disttest passes when it prints Validated SettingsPanel WebUI app contract.; build produces dist. The build does not type-check, so run typecheck as well. Commit package-lock.json.
Look at it in a browser
npm.cmd run devOpen the address printed in the terminal; the page shows preview data. A console message that the font stylesheet failed to load can be ignored.
Part 2: Put it in Unreal
App Definition properties
Right-click in the Content Browser, choose Miscellaneous → Data Asset, and pick the class OrionWebUIAppDefinition. Only AppId has to be filled in; the common properties are below, and the rest keep their defaults.
| Property | Default | Meaning |
|---|---|---|
AppId |
Sample |
Identical to the folder name |
InitialRoute |
empty | The initial route (#/<route>) |
EntryHtml |
index.html |
The entry file inside dist |
bUseDevServerInEditor |
on | In Designer, loads the development server when it is reachable, otherwise dist |
DevServerUrl |
http:// |
Set it to the port in vite.config.ts |
TextCatalog |
empty | Text table; see Text and language |
FontManifest |
empty | Empty uses the project-wide font manifest from Project Settings |
AssetManifest / SoundManifest |
empty | The list of textures and material previews / of business sounds |
BridgeCallTimeoutSeconds |
15 | Seconds to wait for a request |
CursorPolicy |
GameControlled |
Whether the game or the page controls the cursor |
AllowedFrameOrigins / AllowedImageOrigins / AllowedMediaOrigins |
empty | External origins the page may embed; see Security |
bSupportsTransparency |
on | The game shows through where the page is transparent |
Orion WebUI widget properties
| Property | Default | Meaning |
|---|---|---|
| App Definition | empty | The app to load |
InitialRouteOverride |
empty | Overrides the initial route |
bLoadOnConstruct |
on | Loads on construction; when off, use Load App |
bAutoResolveUnhandledRequests |
on | A request nobody answered succeeds with {}. See Tutorial 3 |
| Show Design Time Preview | on | Shows the real page in Designer |
| Design Time Control Id Preview Mode | Hovered Control | Shows the Id of the control under the pointer in Designer; All Controls shows every Id, and a red frame labelled ControlId: <missing> marks a missing one; Hidden turns it off |
Show it, set the input mode, give it focus
- Create a Widget Blueprint, add an Orion WebUI widget, name it
WebUI, enable Is Variable, let it fill its parent, and assign the App Definition. - In the Player Controller, Create Widget and Add to Viewport.
- Set Input Mode Game And UI with
Selfon Player Controller (the node does nothing while that pin is empty) andWebUIon In Widget to Focus; set Set Show Mouse Cursor totrue. - To close: Remove from Parent, restore Set Input Mode Game Only (connect its Player Controller pin too) and hide the cursor.
The Blueprint that receives actions and publishes state is in Tutorial 3.
Preview in UMG Designer
The Orion WebUI widget shows the real page in Designer. There is no bridge there and your Blueprint does not run, so the page must be able to show its own preview data; otherwise it is blank.
| Text shown | Fix |
|---|---|
OrionBrowser dependencies are missing. Click the WebUIStudio button on the toolbar to download them. |
The dependencies are missing or the wrong version. Click the WebUIStudio button on the toolbar to download them |
Orion WebUI AppDefinition is not set. |
Assign an App Definition |
Orion WebUI dist entry is missing: <path> |
The app was not built, or AppId differs from the folder name |
To see edits as you make them, run npm run dev in the app folder and set Dev Server Url to that port (for example http://); if the server starts after Designer is open, compile the Widget Blueprint once. PIE and packaged games load only dist, so run npm run build before PIE.
Where dist is read from
The page is read from Content/. When you rebuild while the editor is running, the page reloads in Designer and in PIE; packaged games do not do this.
- With Use Dev Server in Editor on and a reachable local dev server: loads the dev server, so source edits appear at once
- Otherwise loads
dist - No bridge; the page shows its own preview data
- Always loads
dist - So run
npm run buildbefore PIE - Reloads by itself once
dist/index.htmlhas changed and settled
- Always loads
dist(the copy inside the package) - No automatic reload
- Rebuild, then package again after web changes
Part 3: Messages between the page and Unreal
The interface at a glance
| Page | Unreal | Purpose |
|---|---|---|
api.emit(name, payload) |
On Web Event | Submit an action that needs no result |
api.call(name, payload) |
On Web Request | Needs a result. Resolves on Resolve Json, rejects on Reject with the code in error.code |
api.on(name, handler) |
Post Event to Web, Post Latest Event to Web, Post Retained Latest Event to Web | Receive events from Unreal. Returns a function that removes the listener |
api.handle(name, handler) |
Call Web, On Web Call Completed | Answer a call that Unreal starts |
api.stateCommitted(revision) |
needs no handling | Acknowledges that this revision is on screen |
api.routeChanged(route) |
On Route Changed | The route changed inside the page |
api.playSound(id, options) |
Sound Manifest | Play a business sound |
api.playControlSound(id, "hover" | "click", contextId) |
the control sound policy | Report a control sound by hand for an area without DOM events |
api.bindNativeSurface(id, element) |
Set Native Surface Texture / Material | Place an Unreal picture in the page |
State
- Unreal publishes the full state each time, with a
stateRevisionthat only increases. Publish with Post Retained Latest Event to Web, so a listener registered late still receives the most recent one. - The page stores no game facts: do not put progress, inventory or settings in
localStorage. After submitting an action, wait for the next state instead of assuming success.
Actions
- Name events "prefix plus action", for example
settingsPanel.setVolume, with a prefix unique in the project. Write them as string literals at the call site; do not concatenate. The payload is a plain object. - When a
callis rejected,error.codeis the code Unreal gave; a timeout isE_TIMEOUT. With Auto Resolve Unhandled Requests of the widget cleared, an unanswered request fails withE_UNHANDLED.
Unreal calls the page
releases.push(api.handle("settingsPanel.measure", () => {
return { width: rootElement.value?.offsetWidth ?? 0 };
}));In Blueprint, call Call Web on WebUI with Name set to settingsPanel.measure; the result arrives in On Web Call Completed. A name can have one handler; without one, the result is E_NOT_FOUND. Use this only to ask the page about presentation.
Built-in events
| Event | Content |
|---|---|
ue:bootstrap (once the bridge is up) |
appId, apiVersion, route, localization, assets, sounds, fonts |
ue:localeChanged |
The new text table { culture, texts } |
ue:inputModeChanged |
{ inputType, gamepadName } |
ue:inputPromptsChanged |
{ inputType, gamepadName, actions[] } |
ue:commonAction |
{ actionId, inputType, gamepadName } |
ue:backAction (back) |
{ inputType, gamepadName } |
The last four reach CommonUI pages only.
Presentation phases
data-orion-presentation on the root element is the current phase: preparing (not shown yet), entering, active, covered (another interface is on top), suspended, closing, failed. The page is interactive only in active, and frame loops and WebGL start only then; nothing animates in preparing. When covered or suspended, stop frame loops, timers and WebGL. If Blueprint never calls Set Presentation Lifecycle State, the page enters active by itself once it is ready.
For work of your own that has to start and stop with the presentation, pass callbacks to createOrionWebUILifecycle:
lifecycle = createOrionWebUILifecycle({
api,
root,
onPresentationRequested: () => {
startLoops();
return nextTick();
},
onPresentationSuspended: () => {
stopLoops();
},
});A CommonUI host (OrionWebUIActivatableWidget) calls them on activation and deactivation. On a plain widget, follow the phase itself: in CSS with [data-orion-presentation="active"], in script with a MutationObserver on that attribute.
Declare an entrance animation on a selector that matches both entering and active; one declared for entering alone is cancelled when the phase changes.
Part 4: Rules for writing pages
html,bodyand#appare transparent withoverflow: hidden.- No
backdrop-filter,mix-blend-modeorprefers-reduced-motion; the contract check rejects them. - Every
<img>hasdecoding="async", written before any attribute whose value contains>. - Scripts, styles and fonts come from the app itself; no inline or remote scripts.
- An interface with a fixed composition builds a stage at the design resolution and scales it as a whole.
- Do not write
user-select: none; text is not selectable by default. Where it should be, usedata-orion-user-select. - The browser's native context menu is always suppressed; if you need one, listen for
contextmenuand build it yourself. - Read a local video into memory and play it from a Blob URL; otherwise it cannot seek. Use WebM (VP9 with Opus) or another format Chromium decodes.
- Load heavy modules such as three.js with dynamic
import()and initialise them after the entrance.
Control Ids
Every clickable element needs a data-orion-control-id written as a literal:
- Letters, digits,
.,_and-only; start with a letter or digit; at most 128 characters. - No
:data-orion-control-id="expression", no concatenated variables. Items of the same kind in a list share one Id; the item's identifier goes into the action's payload. - Write disabled as
:disabled="..."and waiting asaria-busy="true". A control disabled only by a style class still makes sound.
Control Ids serve sounds, the Designer inspector and automation, and they do not change with the language.
data-orion-* attributes
| Attribute | Values | Effect |
|---|---|---|
data-orion-control-id |
a stable literal | The identity of a control |
data-orion-action |
an action Id | When that CommonUI action fires, this element is focused and clicked |
data-orion-sound-context |
a context name | Control sounds in this area use the policy with the same name |
data-orion-sound-policy |
none, manual, custom |
No automatic hover and click sounds for this element and its descendants |
data-orion-user-select |
text, all |
Allows text selection |
data-orion-font-policy |
body, heading, none |
Forces the body or heading font; none uses the page's own fonts |
data-orion-presentation |
written by the plugin | The current presentation phase |
Part 5: Input
- Mouse, touch, keyboard: as on any web page; keyboard events need
WebUIto have focus. Use standardbutton,input,selectandlabelelements. - IME: in a key handler, return at once when
event.isComposingis set, and do not move focus during composition. - Gamepad: with a plain widget, Blueprint handles the gamepad and sends commands to the page as events (
ue:station.inputin the supplied sample). A CommonUI page registers actions in an Input Manifest; when one fires, the page receivesue:commonAction, and afterinstallCommonActionRouter(api, root)the element withdata-orion-action="<action Id>"is focused and clicked. For key prompts, listen forue:inputModeChangedandue:inputPromptsChanged. - Drag, hold, rotate: use
createOrionPointerSession()from the shared runtime (Shared/) instead of your own listeners onsrc/ input/ orion-pointer-session.ts window. - Mouse cursor: with
CursorPolicyset toGameControlledthe game decides it; withPageControlled, the page's CSS does. - Moving focus with arrow keys: implement it yourself; the supplied sample does it in
Showcase/.src/ shell/ focus.ts
Part 6: Sound, text, fonts and images
Sound
Hover and click sounds of controls are played by Unreal automatically, with no code in the page: elements with data-orion-control-id, and button, a[href], input, select, textarea, summary and [role="button"], are reported. The policy is set up as in Tutorial 3.
Sounds with a business meaning are requested by the page:
await api.playSound("settings.saved", { volumeMultiplier: 0.8 });The sound Id is a StableId in the Sound Manifest of the App Definition. Error codes: E_SOUND_DISABLED (the app turned web sound off), E_INVALID_ARG, E_SOUND_UNAVAILABLE (not in the manifest, or disabled).
Do not stack the two channels: a button with an automatic click sound should not also playSound; mark an area that plays its own sounds with data-orion-sound-policy="manual". Do not play audio files in the page for interface sounds.
data-orion-control-id="settings-reset"api.playSound("settings.saved")playSound.Text and language
- The simple way: Unreal publishes the language in the state (
culturein this tutorial), and the page uses its own dictionary. - Text Catalog: create an
OrionWebUITextCatalogin Unreal that maps stable keys to Unreal text, and assign it to the App Definition. In the page:
import { type OrionLocalizationTable, translate } from "../../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/src/bridge/orion-webui";
const table = ref<OrionLocalizationTable>({ culture: "", texts: {} });
releases.push(api.on<OrionLocalizationTable>("ue:localeChanged", (next) => { table.value = next; }));
const bootstrap = await lifecycle.initialize();
table.value = bootstrap.localization;
// In the template: {{ translate(table, "settingsPanel.title", "Settings") }}When the language changes, replace the text in the same element. Do not keep one element per language, and do not reload the page.
Fonts
- Bundle fonts with the app: put the font files in
src/assets, declare them with@font-face, and writedata-orion-font-policy="none"on the area that uses them. Keep the font licence files. - Use Unreal font assets: create an
OrionWebUIFontManifest, register Font Face assets and CSS family names in Entries, and assign it toSystemFontManifestin Project Settings → Game → Orion WebUI Font (shared by every app) or toFontManifestof one App Definition.ensureOrionWebUIFontStylesheetinmain.tsattaches the font stylesheet, and CSS takes the family from variables:
font-family: var(--orion-webui-body-font-family, var(--orion-webui-font-family, sans-serif));For headings, use --orion-webui-heading-font-family.
Images
| Image source | Approach |
|---|---|
| Static images of the interface | Put them in src/assets and reference them by relative path or import. WebP is recommended |
| A fixed set of Unreal textures or material previews | Register StableIds in an OrionWebUIAssetManifest and assign it to the App Definition; the page takes url by id from bootstrap.assets. Material previews must be exported in the editor with ExportWebUIAssets of OrionWebUIEditorLibrary, then save the manifest; repeat after every web build |
| Textures known only at run time (avatars, generated icons) | Request Texture Resource in Blueprint; when ready, put the address in the state. See Tutorial 3 |
| Continuously changing pictures (scene capture, video, dynamic materials) | Native Surface, below |
Do not encode images as Base64 into the state, and do not send images through messages every frame.
Live pictures: Native Surface
Put a placeholder element in the page; Unreal draws the picture directly at its position:
<script setup lang="ts">
import OrionNativeSurface from "../../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/src/components/OrionNativeSurface.vue";
</script>
<template>
<div class="capture-frame">
<OrionNativeSurface surface-id="station.capture" class="capture-anchor" />
</div>
</template>Without the component: const release = api.bindNativeSurface("station.capture", element), and call release() on unmount.
surface-idmatchesSurface Idof Set Native Surface Texture in Blueprint character for character.- The picture follows the placeholder's position and size, with translation, scale, opacity and clipping by ancestors; rotation, skew and rounded-corner clipping are not supported.
- By default the picture is drawn above the page; draw the border on an outer container. It receives no mouse input.
- A page can have at most 512 bindings; for many icons in a list, use runtime images.
Part 7: WorldUI
Labels, health bars and interaction buttons that follow Actors are drawn in the same page. Mount the overlay component in the page:
<script setup lang="ts">
import { ref } from "vue";
import OrionWorldOverlayDOM from "../../../../../Plugins/OrionBrowser/Content/UI/WebUI/Shared/src/components/OrionWorldOverlayDOM.vue";
const worldOverlay = ref<InstanceType<typeof OrionWorldOverlayDOM> | null>(null);
</script>
<template>
<OrionWorldOverlayDOM ref="worldOverlay" />
</template>On the Unreal side, add an OrionWebUIWorldElementComponent to the Actor, create a Definition and call Bind Overlay Widget; see Tutorial 3.
By default, when the Definition enables interaction and lists actions, payload.label is shown with action buttons labelled payload.actionLabel, and a click fires On Interaction of the component; otherwise a health bar reads payload.health, payload.maxHealth, payload.label and payload.color. To draw it your own way, register a renderer for the Definition's ElementType:
worldOverlay.value?.registerRenderer("StationCell", ({ container, element }) => {
container.textContent = String(element.payload.label ?? element.key);
});Part 8: Remote websites
Use the Orion Browser widget for external websites, in a Widget of its own: enter the address in Initial URL or call Load URL; use On Load Started, On Load Completed and On Load Error for loading, loaded and failed views, with a Reload button on the failed view; use Go Back, Go Forward and Stop Load for navigation. In Designer it shows placeholder text only.
Part 9: Several pages and routes
One app can contain several pages, told apart by #/<route> in the address. InitialRoute is the default route; InitialRouteOverride on the widget or Set Initial Route Override opens different pages of one app in different widgets. After changing the route inside the page, call api.routeChanged("<route>"); Blueprint receives On Route Changed, and Get Current Route returns the current value. Load the component of each route with dynamic import().
Part 10: Security
- A page can navigate only below its own app address. Navigation elsewhere is blocked and reported through On Web Error as
Blocked Orion WebUI top-level navigation: <address>. - By default the page loads only the app's own resources. To embed external content, enter exact origins in the App Definition (no path, no wildcard):
AllowedFrameOriginsfor<iframe>,AllowedImageOriginsfor images,AllowedMediaOriginsfor audio and video. - Treat everything the page sends as untrusted input: in Unreal, check type, range, whether the object still exists and whether the player may do this. In a networked game, also authorise through your own server.
- Do not put keys or tokens into the page or the state.
Part 11: Performance and configuration
- Do not send data that changes every frame with Post Event to Web every frame; use Native Surface for continuously changing pictures.
- When an uncapped game saturates the GPU, the browser's picture updates are crowded out. A frame limit for the game (Get Game User Settings → Set Frame Rate Limit → Apply Non Resolution Settings) helps more than lowering the browser frame rate.
Hardware-accelerated frame transfer
With it on, the interface's picture goes to Unreal directly on the graphics card, without passing through memory. For full-screen interfaces, high resolutions, or pages with continuous animation, WebGL or video, turn it on in the project's Config/:
[OrionBrowser]
bUseAcceleratedPaint=TrueIt works on Windows with D3D11 and D3D12 only; restart the editor or the game after changing it.
Part 12: Debugging
| Means | Use |
|---|---|
| On Web Error | Connect it to Print String. Bridge errors and blocked navigation arrive here |
| Output Log | Filter by LogOrionWebUI and LogOrionBrowser |
| Web developer tools | Start with -cefdebug=9222, then connect Chrome to that port |
The page's console.log does not reach the Output Log; use the developer tools or the Log tab of WebUIStudio.
Part 13: Check and deliver
After every change, in this order:
npm.cmd run typecheck,npm.cmd test,npm.cmd run build.- Look at every view in WebUIStudio or a browser.
- Confirm in UMG Designer that the page loads and every control has an Id.
- Operate it in PIE: mouse, keyboard, gamepad, IME, both languages, sound, repeated opening and closing.
- Build an up-to-date
dist, package, and go through it again in the packaged game.
Troubleshooting
| Symptom | Check |
|---|---|
The build reports requires package.json orionWebUI appId, interfaceName, ... |
The four fields in package.json and the two values in vite.config.ts disagree |
The build reports must bundle the shared presentation transaction ... |
The page does not use createOrionWebUILifecycle |
npm test reports a missing decoding="async" |
Add it to the <img>; if it is there, move it to the start of the tag |
| The page is blank in Unreal | Are the three names identical? Was it built? Look at On Web Error |
| Blank in Designer, fine in PIE | The page has no preview branch for the no-host case |
| Fine in a browser, an opaque full screen in Unreal | index.html has color-scheme, or the root element has an opaque background |
| Clicks do nothing | Is the root element in the active phase? Do the event names match on both sides? |
| The state does not update | Does the revision increase? Was the listener registered before ready? |
| Designer shows an old page after the dev server changed its port | Dev Server Url was not updated, or another app holds the port |
| The source changed but PIE did not | PIE reads dist; run npm run build again |
| External images, video or pages are blocked | Enter exact origins in the three Allowed...Origins of the App Definition |