Building UI
This guide covers the four ways a mod can put something on the player’s screen: an HTML panel in its own window, a settings page in the Mods menu, an app on the in-game computer, and notices and screen effects. The last section builds a small info panel. It is an HTML window that shows the player’s balance and a press count, and it sends each button press back to the mod.
You can read the four routes in any order. Each one starts from the project in the next section.
What you need
- Obenseuer 0.5.08 with the framework installed (see the install page), and BepInEx 5.4.23 or later (Mono) or BepInEx 6 Unity.Mono.
- The .NET 10 SDK, to build your mod with
dotnet build. - For HTML panels, the WebView2 Runtime. This is the component that shows web pages inside a program. Windows 10 and 11 have it already. If it is missing,
WebUi.OpenreturnsnullandWebUi.IsAvailablebecomesfalse. - For HTML panels, the framework’s optional WebUi module, which is installed with the framework (see install).
Set up the project
The mods in this guide are BepInEx plugins, because they use Update and BepInEx’s settings. A framework mod, like the one in Your first mod, does not have these.
- Do the steps in Your first mod up to the point where it builds. That gives you a folder with
MyFirstMod.csproj,Plugin.csandDirectory.Build.props. - Open the
.csproj. In the<ItemGroup>that holds the other<Reference>lines, add these. They give your code the BepInEx plugin base class, the framework’s WebUi module, the game’s classic UI classes (used by theModUisnippets) and Unity’s keyboard input (used byInput.GetKeyDown):
<Reference Include="BepInEx.Core">
<HintPath>$(BepInExCore)\BepInEx.Core.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="BepInEx.Unity.Mono">
<HintPath>$(BepInExCore)\BepInEx.Unity.Mono.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="ObenseuerCore.WebUi">
<HintPath>$(GameDir)\BepInEx\plugins\ObenseuerCore.WebUi\ObenseuerCore.WebUi.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.InputLegacyModule">
<HintPath>$(ManagedDir)\UnityEngine.InputLegacyModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.UI">
<HintPath>$(ManagedDir)\UnityEngine.UI.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Unity.TextMeshPro">
<HintPath>$(ManagedDir)\Unity.TextMeshPro.dll</HintPath>
<Private>false</Private>
</Reference>
- Your plugin needs the framework and, for HTML panels, WebUi to start first. Put these two lines above your plugin class:
[BepInDependency("obenseuer.moddingframework", ">=0.11.0")]
[BepInDependency("obenseuer.moddingframework.webui", ">=0.11.0")]
- On BepInEx 5, change two things. Write
"0.11.0"instead of">=0.11.0", because BepInEx 5 cannot read a version range, and removeusing BepInEx.Unity.Mono;, becauseBaseUnityPluginthen comes fromBepInEx. The WebUi file isObenseuerCore.WebUi.BepInEx5.dllon BepInEx 5. See Supporting BepInEx 5 too. - The
usinglines each snippet needs:System.IO,System.Reflection,BepInEx,BepInEx.Configuration,BepInEx.Unity.Mono,BepInEx.Unity.Mono.Configuration,ObenseuerCore,ObenseuerCore.UiandUnityEngine.KeyboardShortcutneedsBepInEx.Unity.Mono.Configuration. On BepInEx 5, leave out the twoBepInEx.Unity.Monolines, or wrap them in#if BEPINEX6and#endif, as shown in Supporting BepInEx 5 too. - You can use newer C# than the framework. The framework is built as C# 5, but your project uses the latest version.
UnityEngine.UIModule.dllis not needed.
Pick a route
| You want | Use | Section |
|---|---|---|
| A panel with HTML, CSS and JavaScript | WebUi.Open and WebView | 1 |
| Settings the player edits in the Mods menu | BepInEx config entries, or ModMenu.AddPage for a custom page | 2 |
| An app on the in-game computer | ComputerApps.Register | 3 |
| Notices, hints, subtitles, shake and fades | Hud and ScreenFx | 4 |
1. HTML panels with WebUi
A view is a window that shows a web page from your mod’s web folder. The page is ordinary HTML, CSS and JavaScript. The framework gives that folder an address of the form <modid>.obx, for example example-infopanel.obx. The page is served from there and from nowhere else, apart from the framework’s shared files.
Step 1: ship the web folder
Put the page files in a web folder next to your project file. Then add this to your .csproj. It copies the web folder into the build output each time you build:
<ItemGroup>
<WebFiles Include="web\**\*" /> <!-- every file under the web folder -->
</ItemGroup>
<Target Name="CopyWeb" AfterTargets="Build"> <!-- runs after each build -->
<RemoveDir Directories="$(OutDir)web" /> <!-- clear the old copy -->
<Copy SourceFiles="@(WebFiles)" DestinationFolder="$(OutDir)web\%(RecursiveDir)" /> <!-- copy the new files, keeping folders -->
</Target>
The build then gives this layout:
bin\InfoPanel.dll
bin\web\index.html
bin\web\panel.css
bin\web\app.js
To install, copy bin\InfoPanel.dll and the bin\web\ folder into BepInEx\plugins\InfoPanel\, so the web folder sits next to the DLL.
Pass the page as a relative path, such as index.html. WebUi.Open returns null for a path that starts with / or \, or that contains :, \, % or ...
Step 2: open the view
WebUi.Open makes the window and gives you a WebView to talk to it. This is the code for the plugin’s Update or a key handler. The last argument says whether the view is modal, which means it takes over the game: the player’s controls are locked and the mouse cursor is shown.
string folder = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "web"); // the web folder next to your DLL
view = WebUi.Open(ModId, folder, "index.html", true); // mod id, folder, page, modal or not
if (view == null)
{
Logger.LogWarning("the view did not open: " + WebUi.LastError); // LastError says why
return;
}
view.Message += OnMessage; // be told when the page sends a message
ModId must be 1 to 64 characters of letters, digits, ., _ or -. A bad id throws ArgumentException. Call Open from your mod’s normal code (for example Update), not from a background thread you started.
Pass false for the last argument if the window should open anyway. A non-modal view skips the check for a busy game.
Open returns null when:
modalistrueand the game is busy. That covers a menu, shop or dialogue open, the developer console open, the pause menu active, the main menu showing, or the player’s controls already switched off by another modal view or by computer use.folderdoes not exist, orpageis not a relative path.- The game window does not have focus. The log says the window opens once the game has focus.
- The framework’s web page helper is missing or failed to start. See “If WebView2 is missing” below.
- Technical: the helper is the WebView2 helper,
ObxWebView.dllin the WebUi folder.
- Technical: the helper is the WebView2 helper,
Step 3: link the shared theme
The framework serves two shared style files at https://obx-core.obx/. They make your panel look like the rest of the framework’s windows. Link them from your page:
<link rel="stylesheet" href="https://obx-core.obx/obx-theme.css">
<link rel="stylesheet" href="https://obx-core.obx/obx-base.css">
obx-theme.cssgives you colours, spacing, text sizes and ready-made parts. The parts are the classes.obx-brand,.obx-btn(withprimary,danger,ghost,smorblock),.obx-card,.obx-label,.obx-value,.obx-table,.obx-input,.obx-tabs,.obx-tab,.obx-badgeand.obx-seg.obx-base.cssis optional. It setsbox-sizing, a see-through full-height body with no scrolling and no text selection, and the[hidden]rule.
These are the values you will use most, with their light-theme values:
| Token | Light value | Use |
|---|---|---|
--obx-surface-0 | #e6e6e6 | panel background |
--obx-surface-1 | #f2f2f2 | hover and inset areas |
--obx-text | #333333 | body text |
--obx-muted | #646464 | labels |
--obx-frame | #8c8c8c | panel border |
--obx-amber | #f0a830 | .obx-btn.primary |
--obx-red | #c8281e | .obx-btn.danger |
--obx-s1 to --obx-s4 | 4, 8, 16, 24 px | spacing |
--obx-r-sm, --obx-r-md | 6, 8 px | corner radius |
--obx-fs-sm to --obx-fs-xl | 13, 14, 18, 30 px | font size |
--obx-font | Segoe UI, Bahnschrift, system-ui | font family |
The player can choose a dark theme. Dark values apply under :root[data-theme="dark"]. The framework sets data-theme on <html> before your page runs, from the Appearance.WebTheme setting (light, dark or system). When the setting changes while your view is open, the framework posts {"v":1,"type":"theme","mode":"dark"} to every open view. Handle it in the page, as Step 5 shows.
Step 4: JavaScript to C#
The page sends a message to your mod with the browser’s chrome.webview object. The framework adds no helper, so call it directly. This page code sends { type: "press" } when the button is clicked:
const host = chrome.webview; // the link to your mod
document.getElementById("press").addEventListener("click", () => {
host.postMessage({ type: "press" }); // send a message to your mod
});
Your mod gets the message as plain JSON text in view.Message:
private void OnMessage(string json) // json is the text the page sent
{
if (json.Contains("\"type\":\"press\"")) presses++; // fine for a demo, not for real data
}
- The handler gets the text, not separate fields. Read it yourself, and treat it as untrusted input.
UnityEngine.JsonUtility.FromJson<T>reads it into a[Serializable]class, and needs a reference toUnityEngine.JSONSerializeModule.dll. Thejson.Contains(...)check in the example is fine for a demo, not for real data. - A message longer than 16384 characters is dropped.
- A message whose
typestarts withobx.is dropped. Those names belong to the framework. - A message with no
type, or one that is not a JSON object, reaches your handler as raw text.
Step 5: C# to JavaScript
view.Post sends a JSON object to the page. Build the text yourself:
view.Post("{\"type\":\"state\",\"balance\":" + Wallet.Balance + ",\"presses\":" + presses + "}"); // balance and press count as JSON
The page reads it from the message event. e.data is already a ready-to-use object:
host.addEventListener("message", (e) => { // runs when your mod posts
const m = e.data; // the message as an object
if (m.type === "theme") document.documentElement.setAttribute("data-theme", m.mode); // the player changed the theme
if (m.type === "state") { // your own state message
document.getElementById("balance").textContent = String(m.balance);
document.getElementById("presses").textContent = String(m.presses);
}
});
Post returns false until the page’s web view is attached, so do not post right after Open. Let the page speak first. The page sends {"type":"hello"} when it loads, and the mod answers in OnMessage. The finished example does this.
Step 6: extra folders with MapFolder
view.MapFolder(name, folder) gives a second folder its own address, <name>.<modhost>. For the info panel, view.MapFolder("frames", ...) gives https://frames.example-infopanel.obx/. Use it for pictures and other files:
view.MapFolder("frames", Path.Combine(folder, "frames")); // name, folder on disk
- Files from the extra address can be shown, but pages there cannot send messages to your mod.
- Your page’s security policy must name the extra address. The policy rule
'self'only covers the view’s own address. See “Security” below.
Step 7: hide, close and Escape
view.Show()andview.Hide()toggle the view.view.Close()destroys it. A closed view stays closed, andOpenmakes a new one.- Escape hides a visible view when
view.EscapeHidesistrue, which is the default. With noKeyPressedhandler, that is any visible view. With a handler, your handler runs first, and the framework then hides only a modal view. Setview.EscapeHides = falseto handle Escape yourself, then callHideorClose. view.KeyPressedtells you about two keys. It is a publicAction<int>event. The framework raises it with the key code of Escape (27) or F10 (121), when the key is pressed in the focused view. No other key reaches it, so F9 does not while the panel has focus.- A scene load hides modal views. That is a plain
Hide, soDismisseddoes not run. view.Dismissedruns after the framework hides the view because the player pressed Escape. It does not run when your mod callsHide.- A modal view that is not ready within 20 seconds of
Showcloses itself, and the log says so.
Security
Your page runs in a locked-down window.
- Only the view’s own page can send messages. A page loaded from a
MapFolderaddress cannot. - The framework answers requests to addresses you did not map with
403(not allowed). Your page’s security policy is the second layer, so always set one. - Going to other addresses, opening new windows, downloads, permission questions and other-program links are all blocked. Each block is reported with a name ending in
-blocked, and the view stays open. The report goes to the log and toWebUi.LastError. YourMessagehandler never sees it. - Developer tools and the right-click menu are off. Game objects are not available to the page.
- Service workers (background scripts a page can register) are hidden from the page.
- If the page sends a bare string, your mod receives it wrapped in quotes. So
"hi"arrives with its quotes. - The framework adds no content security policy to your page, so add one. A content security policy (CSP) is a line in the page that lists where it may load files and scripts from. The Mods menu page sets its own. Set one on your page too:
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self' https://obx-core.obx; img-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
With this policy, keep your scripts and styles in their own files, not inside the page. style-src must name https://obx-core.obx when you link the shared style files.
If WebView2 is missing
WebUi.IsAvailable becomes false when the framework’s page helper is missing or WebView2 fails to start. WebUi.LastError says why. After Open returns null, check both. If WebUi is not available, build the same panel as a classic game-style window with ModWindow and ModUi:
if (view == null && !WebUi.IsAvailable)
{
ModWindow.Open(ModId, "Info panel", WindowDock.Center, w => // a classic window in the middle of the screen
{
ModUi.Title(w.Body, "Info panel"); // a heading
ModUi.Text(w.Body, "Presses: " + presses, true); // a line of text
ModUi.Button(w.Body, "Press", ButtonStyle.Primary, () => presses++); // a button that adds one
});
return;
}
Before the first Open, IsAvailable is still true if the helper file is there. Only the Open call shows whether the runtime is installed.
Common mistakes
- Calling any WebUi member from a background thread you started.
Openreturnsnull, theboolmembers returnfalse, andClosedoes nothing. - Passing a page path such as
/index.htmlor../other.html.Openreturnsnull. - A missing
webfolder next to the DLL.Openreturnsnull. - Posting right after
Open. Wait for the page’s first message. - Trusting a message. Read it carefully, check the type, and limit numbers to sensible ranges.
- Using a message type that starts with
obx.. It is dropped. - A security policy without
https://obx-core.obxinstyle-src. The shared style files do not load. - Opening a modal view while a menu is open.
Openreturnsnull.
2. Settings pages in the Mods menu
The Mods menu opens from the Mods tab in the pause menu, and from the MODS button on the main menu. Every loaded plugin that has settings the player can see gets a settings page there. Custom pages made with ModMenu.AddPage are listed next to them.
Step 1: bind config entries
A config entry is one setting your mod saves in its config file. Use BepInEx’s config. The Mods menu builds a row for each entry. This code goes in your plugin:
private ConfigEntry<bool> announce; // an on/off setting
private ConfigEntry<float> volume; // a number setting
private ConfigEntry<KeyboardShortcut> openKey; // a key setting
private void Awake() // runs once when the game starts
{
announce = Config.Bind("General", "Announce", true, "Show a notice when the panel opens"); // section, name, default, help text
volume = Config.Bind("General", "Volume", 0.5f, new ConfigDescription("Alert volume", new AcceptableValueRange<float>(0f, 1f))); // limited to 0 to 1
openKey = Config.Bind("Keys", "Open", new KeyboardShortcut(KeyCode.F9), "Key that opens the panel"); // default key is F9
}
The kind of row depends on the type of the entry:
| Entry type | Row |
|---|---|
bool | toggle |
enum, or a value list (AcceptableValueList) | stepper that cycles through the values |
KeyboardShortcut | key binding: click it, then press a key |
int or float with an AcceptableValueRange | slider |
string, or a type that converts from text | text field |
| any other type | read-only |
Most rows save at once. Sliders save 0.25 seconds after the last change, and again when the game quits.
Step 2: add a custom page
Use ModMenu.AddPage when the page needs more than rows of settings. The framework calls your function when the page is opened, and gives it the page’s content area. You fill it with controls from ModUi. Put this in Awake, after the entries above:
ModMenu.AddPage("example.settingsdemo", "Settings demo", content => // mod id, page title, function that builds the page
{
RectTransform stack = ModUi.Stack(content); // a column to put controls in
ModUi.Title(stack, "Settings demo"); // a heading
ModUi.Toggle(stack, "Announce", announce.Value, v => announce.Value = v); // a switch tied to the setting
ModUi.Slider(stack, "Volume", 0f, 1f, volume.Value, v => volume.Value = v, 2); // a slider from 0 to 1, 2 decimals
});
AddPageadds an entry to the Mods menu’s sidebar. It never adds a tab to the game’s own menu. A page with the same title as another mod’s page is still added, and one warning names both mods. Call it once: a second call from the same mod adds a second page.- The sidebar is read when the Mods window opens. A page added while the window is open shows the next time it opens.
- Your function runs when the player selects the page. If it throws, the page shows a notice and the error goes to the log. The other pages still work.
- When the Mods menu is showing its web page, a custom page opens as a classic pane inside it.
ModMenuWidgetsis obsolete. Build new pages withModUi.
Common mistakes
- Expecting the Mods menu to change the game’s own options. It edits your config. The game’s display and quality options are read only (see settings).
- A
ModUifunction that throws closes the Mods window. It closes with the reasonFault. Keep the functions short, and do the work in your mod. - Expecting a page for a plugin with no visible config entries. It gets no settings page.
3. Apps on the in-game computer
Apps you register appear as icons on the in-game computer desks. When the player clicks one, the framework calls your function with an empty area (the host) for you to fill.
Step 1: register the app
ComputerApps.Register("example.notesapp", "notes", "Notes", null, host => Build(host)); // mod id, app id, title, icon (or null), function that builds the screen
appIdmust not be empty and must not contain/. Registering a second app with the same id returnsfalseand writes a warning that names both mods. The first one stays.RegisterreturnsfalsewhenComputerApps.IsAvailableisfalse, because the desk changes are then not installed.iconmay benull. The title is then drawn on the icon.- The
openfunction receives the host. If it throws, the error goes to your mod’s log and the host is hidden.
Step 2: build into the host
The host is an area of the computer screen. Fill it with ModUi controls. The framework clears it before each open, so build everything again each time.
private void Build(RectTransform host)
{
RectTransform stack = ModUi.Stack(host); // a column to put controls in
ModUi.Title(stack, "Notes"); // a heading
ModUi.Text(stack, note, false); // a line of text, the note
ModUi.Button(stack, "Reset", ButtonStyle.Secondary, () => note = "Buy bread."); // a button that resets the note
}
Keep your state in your mod, not in the host’s children. The framework destroys the host’s children on the next open.
Step 3: an HTML app on the monitor
A WebView can follow the monitor the player is using. This code opens it the first time a monitor is used, pins it to the screen each time the player starts using a computer, and hides it when they stop:
string folder = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "web"); // your web folder
WebView view = null;
ComputerApps.ScreenChanged += s => // s is the monitor's screen, or null when the player stops
{
if (s == null)
{
if (view != null) view.Hide(); // the player left the computer
return;
}
if (view == null) view = WebUi.Open(ModId, folder, "index.html", false); // open it the first time, not modal
if (view == null) return; // the open was refused, so try again next time
view.PinTo(s); // put the view on the monitor
view.Show();
};
ComputerApps.ScreenChanged is raised with the monitor’s screen (a RectTransform), or null when the player stops using it. ComputerApps.ActiveScreen holds the current value. The view is not opened at game start, because Open returns null until the game window has focus. A refused Open is tried again on the next use.
Step 4: full-screen window and world prompts
ComputerApps.AddInteraction(board, "example.notesapp", "Whiteboard", "Read", () => ComputerApps.OpenStandalone("example.notesapp", "notes")); // object, mod id, label, verb, what to do
AddInteractiongives a world object the game’s use prompt. The object needs aCollider(the invisible shape the game uses to know what the player is looking at). It returnsfalsewhen the object already has a game prompt, and a warning is logged once.OpenStandaloneopens the app full screen over the game. It returnsfalsewhen the game is busy.ComputerApps.Close(appId)hides the app.ComputerApps.AppClosedruns when a view closes.
Common mistakes
- A host with no
GraphicRaycasterabove it gets no clicks. The desk host has one. Add one to any canvas you create yourself. - Keeping UI objects in the host. The framework destroys them on the next open.
- An
appIdwith/.Registerreturnsfalseand logs a warning. - A second
Registerwith the sameappId. It returnsfalse.
4. HUD and screen effects
The HUD is what the game shows over the picture: notices, subtitles and hints. ScreenFx is shaking and fading the screen. Put calls like these in an event handler or a key press.
Hud.Notify("Example notice"); // a notice box
Hud.ShowSubtitle("Example subtitle", 2f); // a subtitle, fading out over 2 seconds
Hud.ShowHint("example.hudnotes", "Tip", new[] { "Example hint line." }); // a hint box, owned by your mod id
Hud.HideHint("example.hudnotes"); // remove that hint
ScreenFx.Shake(0.4f, 0.02f); // shake for 0.4 seconds, a small amount
ScreenFx.FadeToBlack(0.1f); // fade to black at speed 0.1
ScreenFx.FadeIn(0.1f); // fade back in
Hud.Notify(text)shows the game’s notice box with the type lineNotice.Hud.Notify(title, body)shows the same box with your own title line.Hud.ShowSubtitle(text, fadeSeconds)types the text on screen, then fades it out. It types 20 characters per second, then fades overfadeSeconds.Hud.ShowHint(modId, title, lines)shows one hint per mod id. Each line is a paragraph. The hint appears in the game’s hint box, the one the card game rules use, so the player can switch it with the Hints key (K by default).Hud.HideHint(modId)removes it.Hud.ShowNote(noteId)opens a note the player already has. It cannot show text of your own.ScreenFx.Shake(duration, amount)shakes the camera. A new shake replaces the one in progress.ScreenFx.FadeToBlack(speed)leaves the screen black untilScreenFx.FadeInruns. Call both.ScreenFx.SetDeathMessage(text)sets the reason shown on the death screen. Call it just before the death screen, because the game replaces the text when it sets its own.- To change the field of view, use
ScreenFx.SetFovOverride(owner, fov)andClearFovOverride(owner). Do not set the camera’s field of view yourself. The game resets it every frame on cameras that have its field-of-view component switched on, so your change is undone on the next frame.- Technical: the component is the game’s
SetCameraFOV, which writes the saved value back every frame. See ScreenFx.
- Technical: the component is the game’s
Common mistakes
- The
HudandScreenFxcalls returnfalseon the main menu or while loading, when the game object they need is missing. Check the result if the message matters. Hud.NotifyandHud.ShowSubtitlethrowArgumentExceptionfor empty text, andHud.ShowHintthrows for an empty title.- A negative
fadeSecondsthrowsArgumentException. ScreenFx.ShakethrowsArgumentExceptionfor a duration of 0 or less, or a negative amount.ScreenFx.FadeToBlackandScreenFx.FadeInthrowArgumentExceptionfor a speed of 0 or less.Hud.ShowHintandHud.HideHintthrowArgumentExceptionfor a mod id that is not 1 to 64 characters of letters, digits,.,_or-.FadeToBlackwithout a laterFadeInleaves the player’s screen black.
The finished mod: info panel
The info panel opens with F9. It shows the player’s balance and a press count. The page asks for the state when it loads, and each button press goes back to the mod, which updates the count and sends the state again.
Make this folder, with the files below in it. InfoPanel.cs takes the place of Plugin.cs in your project. Add the CopyWeb block from Section 1, Step 1 to your .csproj.
InfoPanel\InfoPanel.cs
InfoPanel\web\index.html
InfoPanel\web\panel.css
InfoPanel\web\app.js
InfoPanel.cs is the mod. Update runs every frame, opens or toggles the view when F9 is pressed, and answers the page’s messages.
using System.IO;
using System.Reflection;
using BepInEx;
using BepInEx.Unity.Mono;
using ObenseuerCore;
using UnityEngine;
[BepInPlugin("example.infopanel", "InfoPanel", "0.1.0")]
[BepInDependency("obenseuer.moddingframework", ">=0.11.0")]
[BepInDependency("obenseuer.moddingframework.webui", ">=0.11.0")]
public class InfoPanel : BaseUnityPlugin
{
private const string ModId = "example.infopanel"; // your mod id
private WebView view; // the open window, or null
private int presses; // how many times the button was pressed
private void Update() // runs every frame
{
if (!Input.GetKeyDown(KeyCode.F9)) return; // only act when F9 is pressed
if (view != null && !view.IsClosed)
{
if (view.IsVisible) view.Hide();
else view.Show();
return;
}
string folder = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "web");
view = WebUi.Open(ModId, folder, "index.html", true);
if (view == null)
{
Logger.LogWarning("InfoPanel: the view did not open: " + WebUi.LastError);
return;
}
view.Message += OnMessage;
}
private void OnMessage(string json) // the page sent a message
{
if (json.Contains("\"type\":\"hello\"")) SendState(); // the page loaded and wants the numbers
else if (json.Contains("\"type\":\"press\"")) // the button was pressed
{
presses++;
SendState();
}
}
private void SendState() // send the balance and the count to the page
{
view.Post("{\"type\":\"state\",\"balance\":" + Wallet.Balance + ",\"presses\":" + presses + "}");
}
}
web\index.html is the page. It links the shared style files, sets the security policy, and loads your own style and script.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self' https://obx-core.obx; img-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
<title>Info panel</title>
<link rel="stylesheet" href="https://obx-core.obx/obx-theme.css">
<link rel="stylesheet" href="https://obx-core.obx/obx-base.css">
<link rel="stylesheet" href="panel.css">
</head>
<body>
<main class="panel">
<div class="obx-brand"><span class="obx-brand-a">Info</span><span class="obx-brand-b">Panel</span></div>
<div class="row"><span class="obx-label">Balance</span><span class="obx-value" id="balance">-</span></div>
<div class="row"><span class="obx-label">Presses</span><span class="obx-value" id="presses">0</span></div>
<button class="obx-btn primary block" id="press" type="button">Press me</button>
</main>
<script src="app.js"></script>
</body>
</html>
web\panel.css lays the panel out, using the framework’s tokens.
body { display: flex; align-items: center; justify-content: center; }
.panel {
display: flex;
flex-direction: column;
gap: var(--obx-s2);
min-width: 300px;
padding: var(--obx-s3);
background: var(--obx-surface-0);
border: 1px solid var(--obx-frame);
border-radius: var(--obx-r-md);
}
.row { display: flex; justify-content: space-between; align-items: baseline; }
web\app.js talks to the mod. It sends hello when it loads, sends press when the button is clicked, and shows the numbers the mod sends back.
const host = window.chrome.webview;
const balance = document.getElementById("balance");
const presses = document.getElementById("presses");
document.getElementById("press").addEventListener("click", () => {
host.postMessage({ type: "press" });
});
host.addEventListener("message", (e) => {
const m = e.data;
if (m.type === "theme") document.documentElement.setAttribute("data-theme", m.mode);
if (m.type === "state") {
balance.textContent = String(m.balance);
presses.textContent = String(m.presses);
}
});
host.postMessage({ type: "hello" });
Build with dotnet build -c Release. Copy InfoPanel.dll and the web folder from the build output into BepInEx\plugins\InfoPanel\, as in Section 1, Step 1. Run the game and load a save. Press F9 to open the panel, press the button and watch the count change. Press Escape to hide it, then F9 to show it again. If it does not open, read BepInEx\LogOutput.log for the line that starts InfoPanel: the view did not open.
Where to go next
- WebUi: the full WebUi and WebView reference, including pinning, navigation and the page helper’s messages.
- UI kit: the
ModUiandModWindowkit used by the fallback and the settings pages. - API reference, sections
ModMenuandComputerApps: the Mods menu and computer app references. - HUD:
Hud,ConsoleCommandsand the other HUD calls. - ScreenFx:
ScreenFxin full, including the fade and field-of-view notes. - Settings: the game’s own settings and
Keybinds, which register mod keys in the Mods menu. - Using the framework alongside other mods: what to do when other mods use the same calls.