Orion OrionBrowserManual
中文EN

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

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/UI/WebUI/<AppId>/dist; 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.

UnrealBlueprint owns the state and checks every action
StateRevision
0
Volume
80
Quality
high
Culture
Web pageShows the state, submits actions

Settings

Volume0
Quality

Game scene (stand-in)
    Figure 1Try it: drag the slider, change the quality or the language. The page only submits actions; Unreal checks them and sends the result back. Tick a rule on the left and try again: a rejected value springs back by itself.
    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

    1. 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.
    2. Install Node.js 22 or a newer LTS release (npm is included); building the page needs it.
    3. Put the plugin in the project's Plugins/OrionBrowser. If it is elsewhere, rewrite the relative paths to the plugin in the files below.
    4. Run the supplied sample OrionBrowser/Showcase/L_OrionBrowserOverview once to confirm that the environment works.

    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/UI/WebUI/<AppId>/dist, and packaging collects only that folder.

    Folder nameContent/UI/WebUI/SettingsPanel/
    orionWebUI.appId in package.json"orionWebUI": { "appId": "SettingsPanel", … }
    AppId of the App Definition in UnrealAppId = SettingsPanel

    All 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.

    Figure 2One name in three places.

    .gitignore

    Create it before installing dependencies. Do not ignore dist.

    .gitignore.gitignore
    **/node_modules/
    **/.npm/
    **/.vite/
    **/.cache/
    *.tsbuildinfo
    npm-debug.log*

    package.json

    package.jsonJSON
    {
    	"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 use transactional-screen / transactional; a HUD that is always shown and takes no input uses continuous-passive / continuous-passive.

    tsconfig.json

    tsconfig.jsonJSON
    {
    	"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

    vite.config.tsTypeScript
    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

    index.htmlHTML
    <!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.

    src/bridge/orion-webui.tsTypeScript
    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

    src/main.tsTypeScript
    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.tsTypeScript
    // 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.tsTypeScript
    // 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

    src/App.vueVue
    <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 call initialize(), and only then send settingsPanel.ready.
    • State replaces state: drop a state whose revision is not newer, and acknowledge with stateCommitted after the view has updated. The slider is reset to Unreal's value whenever a state arrives.
    • Catch the failure of every emit and call.
    • The presentation phase controls the root element's visibility: it starts at opacity: 0, is visible in entering and active, and accepts clicks only in active. Do not hide the root with display: none.

    Install, check, build

    Run in the app folder:

    PowerShell
    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 dist

    test 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

    PowerShell
    npm.cmd run dev

    Open the address printed in the terminal; the page shows preview data. A console message that the font stylesheet failed to load can be ignored.

    Figure 3The settings panel built from the code in this tutorial, as it really looks in a browser. There is no Unreal here, so the page takes its “no host” branch and shows preview data (English, volume 80, quality High). The panel is translucent; in the game, the white area is the game picture.

    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://127.0.0.1:5173/ 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

    1. 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.
    2. In the Player Controller, Create Widget and Add to Viewport.
    3. Set Input Mode Game And UI with Self on Player Controller (the node does nothing while that pin is empty) and WebUI on In Widget to Focus; set Set Show Mouse Cursor to true.
    4. 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://127.0.0.1:5180/); 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/UI/WebUI/<AppId>/dist. When you rebuild while the editor is running, the page reloads in Designer and in PIE; packaged games do not do this.

    UMG Designer
    • 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
    PIE
    • Always loads dist
    • So run npm run build before PIE
    • Reloads by itself once dist/index.html has changed and settled
    Packaged game
    • Always loads dist (the copy inside the package)
    • No automatic reload
    • Rebuild, then package again after web changes
    Figure 4Which files each environment loads. The dev server is used in Designer only.

    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 stateRevision that 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 call is rejected, error.code is the code Unreal gave; a timeout is E_TIMEOUT. With Auto Resolve Unhandled Requests of the widget cleared, an unanswered request fails with E_UNHANDLED.

    Unreal calls the page

    TypeScript
    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:

    TypeScript
    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, body and #app are transparent with overflow: hidden.
    • No backdrop-filter, mix-blend-mode or prefers-reduced-motion; the contract check rejects them.
    • Every <img> has decoding="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, use data-orion-user-select.
    • The browser's native context menu is always suppressed; if you need one, listen for contextmenu and 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 as aria-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 WebUI to have focus. Use standard button, input, select and label elements.
    • IME: in a key handler, return at once when event.isComposing is 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.input in the supplied sample). A CommonUI page registers actions in an Input Manifest; when one fires, the page receives ue:commonAction, and after installCommonActionRouter(api, root) the element with data-orion-action="<action Id>" is focused and clicked. For key prompts, listen for ue:inputModeChanged and ue:inputPromptsChanged.
    • Drag, hold, rotate: use createOrionPointerSession() from the shared runtime (Shared/src/input/orion-pointer-session.ts) instead of your own listeners on window.
    • Mouse cursor: with CursorPolicy set to GameControlled the game decides it; with PageControlled, 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:

    TypeScript
    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.

    Control sounds: hover and click, no code in the page
    A control on the pagedata-orion-control-id="settings-reset"
    the plugin reports the control Id
    Control sound policydefault sounds plus per-Id overrides
    Unreal plays itsound assets are chosen in Blueprint
    Business sounds: meaningful moments, requested by the page
    The page callsapi.playSound("settings.saved")
    sound Id
    Sound ManifestStableId entries on the App Definition
    Unreal plays itrejected when the Id is not listed
    Figure 5The two channels do not stack: a button that already has its automatic click sound must not play another through playSound.

    Text and language

    • The simple way: Unreal publishes the language in the state (culture in this tutorial), and the page uses its own dictionary.
    • Text Catalog: create an OrionWebUITextCatalog in Unreal that maps stable keys to Unreal text, and assign it to the App Definition. In the page:
    TypeScript
    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 write data-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 to SystemFontManifest in Project Settings → Game → Orion WebUI Font (shared by every app) or to FontManifest of one App Definition. ensureOrionWebUIFontStylesheet in main.ts attaches the font stylesheet, and CSS takes the family from variables:
    CSS
    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:

    Vue
    <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-id matches Surface Id of 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.
    The Orion WebUI widget on screenWeb pageFrame, title and buttons are drawn by the pageUnreal's picturea render target or a materialPlaceholder element<OrionNativeSurface surface-id="station.capture" />The picture takes itsposition and sizeBlueprintSet Native Surface TextureSurface Id =station.capture
    Figure 6A native surface: Unreal draws its picture exactly where the placeholder element sits, and the pixels never pass through the page. By default it is drawn above the page and takes no mouse input; rounded corners, masks and fades belong in the UI material.

    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:

    Vue
    <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.

    Actors in the levelEnergy cellOrionWebUIWorldElementComponentDefinition · Element Key · Payload JsonTerminalOrionWebUIWorldElementComponentDefinition · Element Key · Payload JsonOne page per local player<OrionWorldOverlayDOM />Energy cell [Collect]Terminal [Deposit]Labels follow their actors across the screenposition, visibility, dataa clicked actionchecked, then On Interaction
    Figure 7WorldUI: actors carry a data component only, and every world label is drawn in one page. Clicking a button on a label fires the component's On Interaction.

    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:

    TypeScript
    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): AllowedFrameOrigins for <iframe>, AllowedImageOrigins for images, AllowedMediaOrigins for 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/DefaultEngine.ini:

    INI
    [OrionBrowser]
    bUseAcceleratedPaint=True

    It 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:

    1. npm.cmd run typecheck, npm.cmd test, npm.cmd run build.
    2. Look at every view in WebUIStudio or a browser.
    3. Confirm in UMG Designer that the page loads and every control has an Id.
    4. Operate it in PIE: mouse, keyboard, gamepad, IME, both languages, sound, repeated opening and closing.
    5. 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

    Type two or more characters

    Contact us

    Questions about the plugin, purchases or partnerships? Send us an email.