Introduction
PrismaUI F4 is a modern UI framework for Fallout 4 that lets F4SE plugin authors build in-game menus, HUDs, and overlays using HTML, CSS, and JavaScript — the same technologies powering the web.
Fallout 4 ships with a legacy Scaleform engine (Flash/ActionScript 3) that is difficult to author for, poorly documented, and impossible to hot-reload. PrismaUI F4 sidesteps all of that by embedding Ultralight, a lightweight WebKit-based HTML renderer, directly into the game process. Your UI lives in a .html file alongside your plugin DLL; the browser handles layout, animation, and scripting.
Why PrismaUI F4?
The problem with vanilla Fallout 4 UI
Bethesda's UI stack is built on Scaleform — a Flash-derived runtime that was discontinued in 2011. Authoring custom menus requires Adobe Animate (formerly Flash Professional), knowledge of ActionScript 3, and a Flash-to-SWF export pipeline that barely works on modern machines. There is no inspector, no hot-reload, and debugging is limited to trace() calls that land in a log file.
What PrismaUI F4 gives you instead
What you can build
PrismaUI F4 is not limited to simple overlays. Anything a browser can render, PrismaUI F4 can display inside Fallout 4.
HUD elements
Live ammo counter, custom health bars, mini-map overlays, buff/debuff trackers — all driven by data pushed from your C++ plugin in real time.
Full-screen menus
Custom inventory systems, crafting interfaces, character sheets, quest journals — complete menus that pause the game and accept keyboard + mouse input.
Notifications & tooltips
Non-intrusive slide-in notifications, context-sensitive tooltips, subtitles, world-space overlays anchored to screen positions.
Developer tools
In-game console panels, stats dashboards, mod configuration screens — UIs you ship with your plugin so users never need to dig through INI files.
How it works
Your HTML/CSS/JS
↓
Ultralight renderer (embedded in PrismaUI_F4.dll)
↓
GPU texture (rendered off-screen, alpha-composited)
↓
Fallout 4 render pipeline (drawn above the game world)
PrismaUI F4 runs as an F4SE plugin (PrismaUI_F4.dll) that loads before your plugin. It initialises Ultralight, manages a pool of views (each view is one HTML page rendered to a texture), and exposes the IVPrismaUI3 C++ interface so your code can:
- Create, show, hide, and destroy views
- Call JavaScript functions inside the page from C++
- Receive JavaScript-to-C++ callbacks via registered listeners
- Pass keyboard and mouse events into focused views (with optional game pause)
Your plugin calls PRISMA_UI_API::RequestPluginAPI<IVPrismaUI3>() once during kGameDataReady to get a pointer to that interface. From there, every UI operation goes through that pointer.
Requirements
Note: You do not need Adobe Animate, Flash, or any Scaleform tooling. PrismaUI F4 replaces that entire pipeline.
Quick start
1. Install the framework
Drop PrismaUI_F4.dll into your mod manager as a standalone mod. It must appear in the load order before any plugin that calls into it. In MO2:
mods/PrismaUI_F4/
└── F4SE/Plugins/
└── PrismaUI_F4.dll
2. Copy the API header
Copy PrismaUI_F4_API.h from the release into your plugin's src/ folder. This is the only header you need — no lib files, no import libraries.
3. Request the interface
#include "PrismaUI_F4_API.h"
static PRISMA_UI_API::IVPrismaUI3* g_api = nullptr;
static void F4SEMessageHandler(F4SE::MessagingInterface::Message* msg)
{
if (msg->type == F4SE::MessagingInterface::kGameDataReady) {
g_api = PRISMA_UI_API::RequestPluginAPI<PRISMA_UI_API::IVPrismaUI3>();
if (!g_api) {
logger::error("PrismaUI_F4 not found — is the framework installed?");
}
}
}
4. Create a view
static PrismaView g_view = 0;
static void OnDomReady(PrismaView /*view*/)
{
// Safe to call Invoke() here — the DOM is ready
logger::info("DOM ready");
}
// Called on kPostLoadGame / kNewGame:
void CreateViews()
{
if (!g_api || g_view != 0) return;
g_view = g_api->CreateView("mymenu.html", OnDomReady);
g_api->Hide(g_view); // views start visible — hide until toggled
}
5. Show and focus the view
bool g_visible = false;
void Toggle()
{
if (!g_api || !g_api->IsValid(g_view)) return;
g_visible = !g_visible;
if (g_visible) {
g_api->Show(g_view);
g_api->Focus(g_view, /*pauseGame=*/true, /*disableFocusMenu=*/false);
} else {
g_api->Unfocus(g_view);
g_api->Hide(g_view);
}
}
6. Write your HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
body {
margin: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Courier New', monospace;
color: #00ff41;
}
.panel {
background: #000;
border: 1px solid #00661a;
padding: 32px 40px;
text-align: center;
}
</style>
</head>
<body>
<div class="panel">
<h1>MY MENU</h1>
<p id="msg">Press the key to close</p>
</div>
<script>
// Called from C++: g_api->Invoke(g_view, "setMessage('Hello!')");
function setMessage(text) {
document.getElementById('msg').textContent = text;
}
</script>
</body>
</html>
HTML files go in your mod folder under PrismaUI_F4/views/:
mods/MyPlugin_F4/
├── F4SE/Plugins/
│ └── MyPlugin_F4.dll
└── PrismaUI_F4/
└── views/
└── mymenu.html
Calling JavaScript from C++
Use Invoke() to run any JavaScript expression inside a view's page:
// Simple call
g_api->Invoke(g_view, "setMessage('Game loaded!')");
// Pass structured data (build JSON string)
std::string js = "onItemSelected(" + std::to_string(itemId) + ")";
g_api->Invoke(g_view, js.c_str());
Timing:
Invoke()is safe to call any time afterOnDomReadyfires for that view.
Receiving callbacks from JavaScript
Register a named listener right after CreateView (no need to wait for OnDomReady), then call it from JavaScript by name:
// C++
g_api->RegisterJSListener(g_view, "onClose", [](const char*) {
Toggle(); // JS called onClose() — hide the menu
});
// JavaScript — call the registered listener by name
document.getElementById('close-btn').addEventListener('click', () => {
onClose();
});
Console output
Register a console callback to see console.log, console.warn, and console.error output from your HTML pages in the F4SE log:
g_api->RegisterConsoleCallback(g_view,
[](PrismaView, PRISMA_UI_API::ConsoleMessageLevel lvl, const char* msg) {
const char* tag =
lvl == PRISMA_UI_API::ConsoleMessageLevel::Error ? "[JS ERR] " :
lvl == PRISMA_UI_API::ConsoleMessageLevel::Warning ? "[JS WARN]" :
"[JS LOG] ";
logger::info("{} {}", tag, msg);
});
Logs land in:
%USERPROFILE%\Documents\My Games\Fallout4\F4SE\MyPlugin_F4.log
Keyboard key codes
Fallout 4 uses Windows Virtual Key (VK) codes for keyboard input — not DirectInput scan codes. If you are porting a Skyrim plugin that used raw DirectInput scan codes, the values will be different.
Use RE::BS_BUTTON_CODE from CommonLibF4 instead of hardcoded hex values:
// Correct — use BS_BUTTON_CODE enum
const uint32_t key = static_cast<uint32_t>(RE::BS_BUTTON_CODE::kF9);
KeyHandler::GetSingleton()->Register(key, KeyEventType::KEY_DOWN, Toggle);
Common values for reference:
If you copy key handler code from a Skyrim example (DirectInput F9 =
0x43), it will silently register the letter 'C' instead.
Next steps
- API Reference — every method, parameter table, and example
- Examples — real plugin code from PrismaInventory_F4 and PrismaShowcase_F4
- HTML Views — view resolution, transparency, z-ordering, and file paths
- View Lifecycle — when to create, show, focus, and destroy views safely
Real, tested patterns extracted from PrismaInventory_F4 and PrismaShowcase_F4.
1. Minimal Toggle Menu
The simplest possible plugin: one view, one hotkey, show/hide with game pause.
C++ (main.cpp):
#include "PrismaUI_F4_API.h"
#include "KeyHandler.h"
#include <spdlog/sinks/basic_file_sink.h>
static PRISMA_UI_API::IVPrismaUI3* g_api = nullptr;
static PrismaView g_view = 0;
static bool g_visible = false;
static void OnDomReady(PrismaView /*view*/) {
logger::info("DOM ready");
}
static void Toggle() {
if (!g_api || !g_api->IsValid(g_view)) return;
g_visible = !g_visible;
if (g_visible) {
g_api->Show(g_view);
g_api->Focus(g_view, true, false); // pauseGame=true
} else {
g_api->Unfocus(g_view);
g_api->Hide(g_view);
}
}
static void F4SEMessageHandler(F4SE::MessagingInterface::Message* msg) {
switch (msg->type) {
case F4SE::MessagingInterface::kGameDataReady:
g_api = PRISMA_UI_API::RequestPluginAPI<PRISMA_UI_API::IVPrismaUI3>();
KeyHandler::RegisterSink();
KeyHandler::GetSingleton()->Register(static_cast<uint32_t>(RE::BS_BUTTON_CODE::kF9), KeyEventType::KEY_DOWN, Toggle);
break;
case F4SE::MessagingInterface::kPostLoadGame:
case F4SE::MessagingInterface::kNewGame:
if (g_api && g_view == 0) {
g_view = g_api->CreateView("mymenu.html", OnDomReady);
g_api->RegisterConsoleCallback(g_view,
[](PrismaView, PRISMA_UI_API::ConsoleMessageLevel lvl, const char* msg) {
logger::info("[JS] {}", msg);
});
g_api->Hide(g_view);
}
break;
}
}
HTML (mymenu.html):
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { width:100vw; height:100vh; background:transparent;
display:flex; align-items:center; justify-content:center;
font-family:'Courier New',monospace; }
.panel { background:rgba(0,0,0,0.9); border:1px solid #00661a;
padding:40px; color:#00ff41; text-align:center; }
button { margin-top:20px; padding:10px 24px; background:transparent;
border:1px solid #00661a; color:#00ff41; font-family:'Courier New',monospace;
font-size:12px; cursor:pointer; letter-spacing:2px; }
button:hover { background:rgba(0,255,65,0.1); }
</style>
</head>
<body>
<div class="panel">
<h2 style="letter-spacing:3px">MY MENU</h2>
<p style="margin-top:12px;color:#009921;font-size:11px">Game is paused.</p>
<button onclick="requestClose()">CLOSE</button>
</div>
<script>
// requestClose is registered from C++ via RegisterJSListener
console.log('mymenu ready');
</script>
</body>
</html>
Wire up close from C++ in OnDomReady:
static void OnDomReady(PrismaView /*view*/) {
g_api->RegisterJSListener(g_view, "requestClose", [](const char*) {
g_visible = false;
g_api->Unfocus(g_view);
g_api->Hide(g_view);
});
}
2. Pushing Structured Data to a View
Push game data to the HTML page using InteropCall with JSON.
C++ — build and push inventory:
#include <nlohmann/json.hpp> // or build JSON manually with sprintf
static void PushInventoryData(PrismaView view) {
// Collect items from game
auto* player = RE::PlayerCharacter::GetSingleton();
if (!player) return;
std::string json = "[";
bool first = true;
player->GetInventory([&](RE::TESBoundObject& obj, const RE::InventoryEntryData& entry) {
if (!first) json += ",";
first = false;
// Escape name for JSON (simplified — use a proper JSON lib in production)
json += "{\"name\":\"" + std::string(obj.GetFullName()) + "\""
+ ",\"count\":" + std::to_string(entry.countDelta)
+ ",\"weight\":" + std::to_string(obj.GetWeight())
+ "}";
});
json += "]";
g_api->InteropCall(view, "loadInventory", json.c_str());
}
HTML — receive and render:
<div id="list"></div>
<script>
function loadInventory(jsonStr) {
var items = JSON.parse(jsonStr);
var html = '';
items.forEach(function(item) {
html += '<div class="row">'
+ '<span class="name">' + escapeHtml(item.name) + '</span>'
+ '<span class="count">x' + item.count + '</span>'
+ '</div>';
});
document.getElementById('list').innerHTML = html;
}
function escapeHtml(str) {
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
</script>
3. Receiving Events from JavaScript
The JS → C++ direction uses RegisterJSListener. Each listener is a global function injected into the page's JS context.
C++ — register multiple listeners:
static void OnDomReady(PrismaView view) {
// Close button
g_api->RegisterJSListener(view, "requestClose", [](const char*) {
g_api->Unfocus(g_view);
g_api->Hide(g_view);
g_visible = false;
});
// Setting changed
g_api->RegisterJSListener(view, "onSettingChanged", [](const char* json) {
// e.g. json = '{"key":"hungerRate","value":"75"}'
logger::info("Setting: {}", json);
// Parse and apply in-game...
});
// Item selected
g_api->RegisterJSListener(view, "onItemSelected", [](const char* formIdStr) {
uint32_t formId = std::stoul(formIdStr, nullptr, 16);
auto* form = RE::TESForm::GetFormByID(formId);
if (form) logger::info("Selected: {}", form->GetFullName());
});
}
HTML — call from buttons and inputs:
// Close
document.getElementById('closeBtn').onclick = function() {
requestClose();
};
// Setting slider
document.getElementById('hungerSlider').oninput = function() {
onSettingChanged(JSON.stringify({ key: 'hungerRate', value: this.value }));
};
// Item click
document.querySelectorAll('.item-row').forEach(function(el) {
el.onclick = function() {
onItemSelected(this.dataset.formid);
};
});
4. Reading a Value Back from JS
Invoke with a callback lets you pull a value from the page's JS state.
// Ask the page what the current slider value is
g_api->Invoke(view,
"document.getElementById('volumeSlider').value",
[](const char* result) {
int volume = std::atoi(result);
logger::info("Volume is {}", volume);
// Apply to game audio...
});
For passing complex state back, have JS call a listener instead — it's cleaner than parsing Invoke results.
5. Four Views, Four Keys (Showcase Pattern)
Managing multiple views from a single plugin. Each view is independent.
static constexpr uint32_t KEYS[4] = {
static_cast<uint32_t>(RE::BS_BUTTON_CODE::kF9),
static_cast<uint32_t>(RE::BS_BUTTON_CODE::kF10),
static_cast<uint32_t>(RE::BS_BUTTON_CODE::kF11),
static_cast<uint32_t>(RE::BS_BUTTON_CODE::kF12),
};
static constexpr const char* FILES[4] = { "levelup.html", "mcm.html",
"companion.html", "terminal.html" };
static constexpr const char* NAMES[4] = { "LevelUp", "MCM", "Companion", "Terminal" };
static PRISMA_UI_API::IVPrismaUI3* g_api = nullptr;
static PrismaView g_views[4] = {};
static bool g_visible[4] = {};
static void Toggle(int idx) {
if (!g_api || !g_api->IsValid(g_views[idx])) return;
g_visible[idx] = !g_visible[idx];
if (g_visible[idx]) {
g_api->Show(g_views[idx]);
g_api->Focus(g_views[idx], true, false);
} else {
g_api->Unfocus(g_views[idx]);
g_api->Hide(g_views[idx]);
}
}
static void CreateViews() {
for (int i = 0; i < 4; i++) {
if (g_views[i] != 0) continue;
g_views[i] = g_api->CreateView(FILES[i], nullptr);
g_api->RegisterConsoleCallback(g_views[i],
[](PrismaView, PRISMA_UI_API::ConsoleMessageLevel, const char* msg) {
logger::info("[JS] {}", msg);
});
g_api->Hide(g_views[i]);
}
}
static void F4SEMessageHandler(F4SE::MessagingInterface::Message* msg) {
switch (msg->type) {
case F4SE::MessagingInterface::kGameDataReady:
g_api = PRISMA_UI_API::RequestPluginAPI<PRISMA_UI_API::IVPrismaUI3>();
KeyHandler::RegisterSink();
for (int i = 0; i < 4; i++) {
KeyHandler::GetSingleton()->Register(KEYS[i], KeyEventType::KEY_DOWN,
[i]() { Toggle(i); });
}
break;
case F4SE::MessagingInterface::kPostLoadGame:
case F4SE::MessagingInterface::kNewGame:
if (g_api) CreateViews();
break;
}
}
6. Z-Ordering Two Overlapping Views
// Background HUD — always visible, no focus, low z-order
g_hudView = g_api->CreateView("hud.html", nullptr);
g_api->SetOrder(g_hudView, 0);
g_api->Show(g_hudView); // visible but unfocused
// Popup menu — appears on top of HUD when opened
g_menuView = g_api->CreateView("menu.html", OnMenuReady);
g_api->SetOrder(g_menuView, 10);
g_api->Hide(g_menuView);
The HUD renders continuously. When the menu is opened with Show + Focus, it renders over the HUD because its order value (10) is higher.
7. Updating a HUD Each Second
For a heads-up display that shows live stats (health, ammo, time), push updates from a recurring task rather than every frame.
// In OnDomReady — schedule repeating updates via a recurring task
static void ScheduleHudUpdate() {
F4SE::GetTaskInterface()->AddTask([]() {
if (!g_api || !g_api->IsValid(g_hudView) || g_api->IsHidden(g_hudView)) {
ScheduleHudUpdate(); // keep scheduling even when hidden
return;
}
auto* player = RE::PlayerCharacter::GetSingleton();
if (!player) { ScheduleHudUpdate(); return; }
std::string json = "{\"hp\":" + std::to_string((int)player->GetActorValue(RE::ActorValue::kHealth))
+ ",\"ap\":" + std::to_string((int)player->GetActorValue(RE::ActorValue::kActionPoints))
+ "}";
g_api->InteropCall(g_hudView, "updateStats", json.c_str());
// Re-schedule via timer (1000ms)
// In practice, hook a game timer or use a separate thread
ScheduleHudUpdate();
});
}
For a real HUD, hook the game's tick or use a 1-second timer rather than a tight recursion loop. The example above shows the pattern; adjust the scheduling mechanism to your needs.
8. Inspector Setup (Development Only)
#ifdef PRISMA_DEV
static void SetupInspector(PrismaView view) {
api->CreateInspectorView(view);
// Position inspector on right half of a 1920-wide screen
api->SetInspectorBounds(view, 960.0f, 0.0f, 960, 600);
api->SetInspectorVisibility(view, true);
}
// Bind F12 to toggle inspector during dev
KeyHandler::GetSingleton()->Register(0x58, KeyEventType::KEY_DOWN, []() {
bool v = api->IsInspectorVisible(g_view);
api->SetInspectorVisibility(g_view, !v);
});
#endif
Remove all CreateInspectorView calls before releasing your mod.
9. Error-Resilient View Creation
static void CreateViews() {
if (!g_api) {
logger::error("PrismaUI not available — is PrismaUI_F4.dll installed and loaded?");
return;
}
if (g_view != 0) return; // already created
try {
g_view = g_api->CreateView("mymenu.html", OnDomReady);
} catch (const std::exception& e) {
logger::error("CreateView failed: {}", e.what());
return;
}
if (!g_api->IsValid(g_view)) {
logger::error("Created view is immediately invalid — check that mymenu.html exists in Data/PrismaUI_F4/views/");
g_view = 0;
return;
}
g_api->RegisterConsoleCallback(g_view,
[](PrismaView, PRISMA_UI_API::ConsoleMessageLevel lvl, const char* msg) {
if (lvl == PRISMA_UI_API::ConsoleMessageLevel::Error)
logger::error("[JS ERR] {}", msg);
else
logger::info("[JS] {}", msg);
});
g_api->Hide(g_view);
logger::info("View created (id={})", g_view);
}
The most common failure modes:
g_apiis null → PrismaUI_F4 is not installed or failed to loadIsValidreturns false immediately → the HTML file path is wrong- JS errors in console → check HTML/JS syntax errors, missing function guards
10. Localized UI with Translations
Load the player's language automatically and use window.t() in your HTML to display translated strings.
Mod folder layout:
mods/MyPlugin_F4/
├── F4SE/Plugins/MyPlugin_F4.dll
├── PrismaUI_F4/views/mymenu.html
└── Interface/Translations/
├── MyPlugin_F4_en.txt
└── MyPlugin_F4_de.txt
Translation file (MyPlugin_F4_en.txt, saved as UTF-16 LE with BOM):
$MENU_TITLE My Inventory
$CLOSE Close
$EMPTY No items found
$ITEM_COUNT Items
C++ — register translations right after CreateView:
g_view = g_api->CreateView("mymenu.html", OnDomReady);
g_api->RegisterTranslations(g_view, "MyPlugin_F4");
g_api->Hide(g_view);
HTML — use window.t() anywhere in your page:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8">
<style>
body { margin:0; background:rgba(0,0,0,0.85); color:#f0e6d2;
font-family:'Courier New',monospace; padding:32px; }
h1 { font-size:1.4rem; letter-spacing:3px; }
#list { margin-top:16px; }
button { margin-top:24px; padding:8px 20px; background:transparent;
border:1px solid #8b7355; color:#f0e6d2; cursor:pointer; }
</style>
</head>
<body>
<h1 id="title"></h1>
<div id="list"></div>
<button id="close-btn"></button>
<script>
// window.t is available immediately — injected before this script runs
document.getElementById('title').textContent = window.t('$MENU_TITLE');
document.getElementById('close-btn').textContent = window.t('$CLOSE');
function loadInventory(jsonStr) {
var items = JSON.parse(jsonStr);
var list = document.getElementById('list');
if (items.length === 0) {
list.textContent = window.t('$EMPTY');
return;
}
list.innerHTML = '<p>' + window.t('$ITEM_COUNT') + ': ' + items.length + '</p>';
}
</script>
</body>
</html>
You can build PrismaUI F4 views with React, Vue, Svelte, or any other frontend framework. This guide explains the one timing problem you will hit and the pattern that solves it.
The Timing Problem
When you use a modern framework, there is a specific ordering issue between F4SE sending data and your framework mounting components:
- F4SE calls
CreateView()— the HTML file starts loading - DOM becomes ready —
OnDomReadyCallbackfires on the C++ side - F4SE sends initial data via
Invoke()orInteropCall() - Your framework initializes and mounts components
- Components register their event handlers inside lifecycle hooks
Steps 3 and 4 can arrive in the wrong order. If F4SE sends data before your React components have mounted and registered their window functions, that data is silently lost.
The common mistake
// Wrong — window.receiveData does not exist yet when F4SE calls it
function MyComponent() {
useEffect(() => {
window.receiveData = (data) => {
setState(JSON.parse(data));
};
}, []);
}
The Solution: Register at Module Load Time
Register all window.* functions outside any component or lifecycle hook, at the top level of a module that is imported before your framework renders. Use an external state manager (Zustand works well) so those functions can write to state that components read reactively.
src/
├── store/
│ └── gameStore.ts <- window.* registration + Zustand store
├── components/
│ └── PlayerHUD.tsx
├── main.tsx <- imports store BEFORE ReactDOM.render
└── index.html
Step 1 — Create the Store and Register Window Functions
// src/store/gameStore.ts
import { create } from 'zustand';
interface PlayerData {
health: number;
maxHealth: number;
name: string;
}
interface GameStore {
player: PlayerData | null;
isReady: boolean;
setPlayer: (data: PlayerData) => void;
updateHealth: (health: number) => void;
}
export const useGameStore = create<GameStore>((set) => ({
player: null,
isReady: false,
setPlayer: (data) => set({ player: data, isReady: true }),
updateHealth: (health) =>
set((state) => ({
player: state.player ? { ...state.player, health } : null,
})),
}));
// These run IMMEDIATELY when the module is imported.
// F4SE can safely call them as soon as OnDomReadyCallback fires.
window.initializePlayer = (jsonData: string) => {
try {
useGameStore.getState().setPlayer(JSON.parse(jsonData));
} catch (e) {
console.error('[PrismaUI] initializePlayer parse error:', e);
}
};
window.updatePlayerHealth = (value: string) => {
useGameStore.getState().updateHealth(parseFloat(value));
};
console.log('[PrismaUI] F4SE bridge registered');
Step 2 — Import the Store Before React Renders
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
// Import FIRST so window.* functions exist before React starts
import './store/gameStore';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Step 3 — Read from the Store in Components
// src/components/PlayerHUD.tsx
import { useGameStore } from '../store/gameStore';
export function PlayerHUD() {
const player = useGameStore((state) => state.player);
const isReady = useGameStore((state) => state.isReady);
if (!isReady || !player) {
return <div className="hud-loading">Connecting...</div>;
}
return (
<div className="hud">
<span className="name">{player.name}</span>
<div
className="health-bar"
style={{ width: `${(player.health / player.maxHealth) * 100}%` }}
/>
</div>
);
}
Step 4 — F4SE Side (C++)
Send data as soon as OnDomReadyCallback fires. By that point your JS bundle has already loaded and all window.* functions are registered.
static void OnDomReady(PrismaView view)
{
// Build initial player data and push it
std::string json =
"{\"name\":\"Sole Survivor\",\"health\":100,\"maxHealth\":100}";
std::string script = "window.initializePlayer('" + json + "')";
g_api->Invoke(view, script.c_str());
// For high-frequency updates later, use InteropCall (faster than Invoke)
// g_api->InteropCall(view, "updatePlayerHealth", "95");
}
Sending Data Back to F4SE
Register JS listeners immediately after CreateView (before OnDomReady — this is safe, registration does not need the JS context). Call them from JavaScript by function name.
// Register right after CreateView, no need to wait for OnDomReady
g_view = g_api->CreateView("mymenu.html", OnDomReady);
g_api->RegisterJSListener(g_view, "onSettingChanged", [](const char* data) {
logger::info("Setting changed: {}", data);
});
g_api->RegisterJSListener(g_view, "onClose", [](const char*) {
g_api->Unfocus(g_view);
g_api->Hide(g_view);
});
g_api->Hide(g_view);
// src/lib/f4se-api.ts
export const F4SE_API = {
sendToF4SE: (fnName: string, data?: string) => {
try {
(window as Record<string, unknown>)[fnName]?.(data);
} catch {
/* silent in production */
}
},
};
function SettingsPanel() {
const handleClose = () => F4SE_API.sendToF4SE('onClose');
const handleSave = (settings: object) =>
F4SE_API.sendToF4SE('onSettingChanged', JSON.stringify(settings));
return (
<div>
<button onClick={handleClose}>Close</button>
<button onClick={() => handleSave({ volume: 0.8 })}>Save</button>
</div>
);
}
TypeScript Window Declarations
Declare F4SE-injected functions and translation globals on Window to get proper types:
// src/global.d.ts
export {};
declare global {
interface Window {
// F4SE JS listeners
initializePlayer: (json: string) => void;
updatePlayerHealth: (value: string) => void;
onClose: (arg: string) => void;
onSettingChanged: (data: string) => void;
// PrismaUI translations (injected by RegisterTranslations before any script runs)
t: (key: string) => string;
L10N: Record<string, string>;
}
}
window.t and window.L10N are available before your framework initializes — they are injected at the OnWindowObjectReady stage, earlier than DOMContentLoaded. You can call window.t('$KEY') safely from module-level code, store initializers, and component render functions without any guards.
// Safe at any point — translations are ready before JS runs
const LABELS = {
close: window.t('$CLOSE'),
title: window.t('$MY_MENU_TITLE'),
};
If translations are optional in your plugin, guard the call:
const t = (key: string) => window.t?.(key) ?? key;
Development Mode
In a real browser (dev server) there is no F4SE to inject data. Guard dev-only behaviour with import.meta.env.DEV:
// src/app.tsx
export const App = () => {
useEffect(() => {
if (import.meta.env.DEV) {
// Inject mock data so UI is visible in the browser
window.initializePlayer(JSON.stringify({
name: 'Sole Survivor',
health: 85,
maxHealth: 100,
}));
}
}, []);
return <PlayerHUD />;
};
Key Rules
Overview
The public API is declared entirely in PrismaUI_F4_API.h. Copy that single header into your plugin's src/ folder. You do not link against PrismaUI_F4 at compile time; the connection is made at runtime via GetProcAddress.
#include "PrismaUI_F4_API.h"
// On kGameDataReady:
auto* api = PRISMA_UI_API::RequestPluginAPI<PRISMA_UI_API::IVPrismaUI3>();
Types
PrismaView
typedef uint64_t PrismaView;
An opaque handle that identifies one HTML view. The value 0 means "no view" / invalid. Always check IsValid(view) before using a handle you haven't used recently, particularly after a game reload.
ConsoleMessageLevel
enum class ConsoleMessageLevel : uint8_t {
Log = 0,
Warning,
Error,
Debug,
Info
};
Passed to ConsoleMessageCallback. Maps directly to the JavaScript console.* level.
Callback Types
// Called once when the HTML document's DOM is fully parsed and ready.
typedef void (*OnDomReadyCallback)(PrismaView view);
// Called with the string result of a JS expression evaluated via Invoke().
typedef void (*JSCallback)(const char* result);
// Called when JS code calls the registered listener function on window.
typedef void (*JSListenerCallback)(const char* argument);
// Called for every console.log/warn/error line from JS.
typedef void (*ConsoleMessageCallback)(
PrismaView view,
ConsoleMessageLevel level,
const char* message
);
All callbacks are invoked on the main game thread.
Always request the API during kGameDataReady. If PrismaUI_F4 is not installed or is outdated, RequestPluginAPI returns nullptr, handle this gracefully.
auto* api = PRISMA_UI_API::RequestPluginAPI<PRISMA_UI_API::IVPrismaUI3>();
if (!api) {
logger::error("PrismaUI_F4 not found. Is the framework installed?");
}
RequestPluginAPI
template <typename T>
[[nodiscard]] inline T* RequestPluginAPI();
Locates PrismaUI_F4.dll in the current process via GetModuleHandleW, calls its exported RequestPluginAPI function, and casts the result. Returns nullptr if:
- PrismaUI_F4 is not loaded
- The loaded version does not support the requested interface
Call timing: During or after F4SE::MessagingInterface::kGameDataReady. Do not call during F4SEPlugin_Load or F4SEPlugin_Query; F4SE may not have loaded PrismaUI_F4 yet.
API Methods
CreateView
virtual PrismaView CreateView(
const char* htmlPath,
OnDomReadyCallback onDomReadyCallback = nullptr
) noexcept = 0;
Creates an HTML view and begins loading the specified file.
Returns a non-zero PrismaView handle on success. The view starts visible, always call Hide(view) immediately after creation unless you want it to appear on screen right away.
Z-order: Views are automatically assigned a z-order on creation equal to the current highest order + 1 (first view = 0, second = 1, etc.). Override with SetOrder if needed.
Thread safety: Call from the main thread (e.g., inside an F4SE message handler).
Create views on kPostLoadGame / kNewGame, not on kGameDataReady. The view system is ready after a game is loaded, not immediately at plugin init.
Invoke
virtual void Invoke(
PrismaView view,
const char* script,
JSCallback callback = nullptr
) noexcept = 0;
Evaluates an arbitrary JavaScript expression in the view's context.
Example:
// Push data into the page
api->Invoke(view, "updateInventory('[{\"name\":\"Stimpack\",\"count\":5}]')");
// Read a value back
api->Invoke(view, "document.getElementById('hp').textContent", [](const char* val) {
logger::info("HP display: {}", val);
});
Encoding: Invoke validates the script string as UTF-8 and automatically converts from ANSI if needed. Item names from the game's string tables are often ANSI, this conversion is handled for you. If the conversion fails entirely, the call is silently dropped — no callback fires.
Silent failures: Invoke is a no-op and fires no callback if view is 0 or destroyed, or if script is nullptr. Check IsValid(view) before calling if the handle may be stale. If the JS expression throws an exception at runtime, callback is still called — with an empty string "" as the result.
Performance: Invoke parses and evaluates an arbitrary JS expression on the Ultralight thread. For high-frequency calls, prefer InteropCall.
InteropCall
virtual void InteropCall(
PrismaView view,
const char* functionName,
const char* argument
) noexcept = 0;
Calls a named JavaScript function via the JS Interop API (lower overhead than Invoke). The function must be defined on the window object. The argument is passed as a single string parameter.
Example:
// C++
api->InteropCall(view, "onInventoryData", jsonString.c_str());
// JS (mymenu.html)
function onInventoryData(json) {
var items = JSON.parse(json);
// render items...
}
Encoding: Same ANSI to UTF-8 auto-conversion as Invoke. Same silent failure behaviour — no-op if view is invalid or arguments are null.
Why it's faster than Invoke: InteropCall bypasses the JS expression parser entirely. It calls the named function directly through the Ultralight JS interop interface, skipping string tokenisation and evaluation. Use it for anything called repeatedly (every frame, every stat update). Use Invoke when you need a return value or are evaluating an expression rather than calling a named function — InteropCall cannot return values.
Note: argument cannot be nullptr — the call is silently dropped. Pass "" if you have no data to send.
RegisterJSListener
virtual void RegisterJSListener(
PrismaView view,
const char* functionName,
JSListenerCallback callback
) noexcept = 0;
Exposes a C++ callback to JavaScript. After registration, calling functionName() from JS invokes the C++ callback with the first argument serialized to a string.
Timing: RegisterJSListener is safe to call immediately after CreateView, before OnDomReady fires. It registers a C++ callback and does not require the JS context to be ready. Invoke is the call that requires OnDomReady.
Example:
// C++ — register in OnDomReady
api->RegisterJSListener(view, "onCloseRequest", [](const char* /*arg*/) {
api->Unfocus(view);
api->Hide(view);
});
// JS — call from the page
onCloseRequest(); // close with no data
onCloseRequest('{"ok":1}'); // close with a payload
HasFocus
virtual bool HasFocus(PrismaView view) noexcept = 0;
Returns true if this view currently has input focus. When focused, keyboard and mouse input goes to the HTML view rather than the game.
Focus
virtual bool Focus(
PrismaView view,
bool pauseGame = false,
bool disableFocusMenu = false
) noexcept = 0;
Gives input focus to the view. This:
- Routes keyboard and mouse events to the HTML page
- Makes the game cursor visible and releases
ClipCursor - Optionally pauses game time
Returns true if the operation was successfully enqueued — not if focus was actually granted. The actual focus attempt runs asynchronously. If the view is hidden when the operation executes, focus is silently skipped. Always call Show before Focus.
Calling Focus on a view that already has focus is safe and does nothing.
Unfocus
virtual void Unfocus(PrismaView view) noexcept = 0;
Removes focus from the view. Restores game input, hides the cursor, and re-engages ClipCursor. If pauseGame was true on Focus, game time is restored.
Call Hide after Unfocus if you want the view invisible while not in use.
Show
virtual void Show(PrismaView view) noexcept = 0;
Makes a hidden view visible. The view is composited on top of the game image at the next Present call. Does not grant input focus.
Hide
virtual void Hide(PrismaView view) noexcept = 0;
Removes a visible view from the composite. Does not destroy the view or stop JavaScript execution. If the view currently has focus, Hide automatically unfocuses it — you do not need to call Unfocus first, though doing so explicitly is still good practice for clarity.
IsHidden
virtual bool IsHidden(PrismaView view) noexcept = 0;
Returns true if the view is currently hidden.
GetScrollingPixelSize
virtual int GetScrollingPixelSize(PrismaView view) noexcept = 0;
Returns the number of pixels scrolled per mouse wheel tick. Default: 28 px.
SetScrollingPixelSize
virtual void SetScrollingPixelSize(PrismaView view, int pixelSize) noexcept = 0;
Sets the mouse wheel scroll amount in pixels.
IsValid
virtual bool IsValid(PrismaView view) noexcept = 0;
Returns true if the view handle is live and backed by a real Ultralight view. Check this before any operation if the view might have been destroyed or not yet created.
Destroy
virtual void Destroy(PrismaView view) noexcept = 0;
Tears down the view completely, freeing all resources (Ultralight view, D3D11 textures, JS context). The handle becomes invalid after this call. You rarely need this; views are typically kept alive for the session.
SetOrder
virtual void SetOrder(PrismaView view, int order) noexcept = 0;
Sets the rendering z-order. Higher values render on top. Default is 0 for all views. When two views overlap, the one with the higher order is drawn over the other.
GetOrder
virtual int GetOrder(PrismaView view) noexcept = 0;
Returns the current z-order of the view.
CreateInspectorView
virtual void CreateInspectorView(PrismaView view) noexcept = 0;
Attaches an Ultralight inspector (developer tools) to the view. Must be called once before using any other inspector methods. The inspector view is a separate HTML view containing the WebKit DevTools UI.
The inspector is per-view — it only inspects the specific view you pass. If you have multiple views, you need a separate CreateInspectorView call for each one you want to debug. In practice, create an inspector only for the view you are currently working on.
SetInspectorVisibility
virtual void SetInspectorVisibility(PrismaView view, bool visible) noexcept = 0;
Shows or hides the inspector overlay. The inspector must have been created with CreateInspectorView first.
IsInspectorVisible
virtual bool IsInspectorVisible(PrismaView view) noexcept = 0;
Returns true if the inspector overlay is currently visible.
SetInspectorBounds
virtual void SetInspectorBounds(
PrismaView view,
float topLeftX,
float topLeftY,
unsigned int width,
unsigned int height
) noexcept = 0;
Positions and sizes the inspector overlay in screen pixels.
HasAnyActiveFocus
virtual bool HasAnyActiveFocus() noexcept = 0;
Returns true if any PrismaUI view currently has input focus. Useful for suppressing game hotkeys while a menu is open.
Console Callback
RegisterConsoleCallback
virtual void RegisterConsoleCallback(
PrismaView view,
ConsoleMessageCallback callback
) noexcept = 0;
Registers a callback that receives all console.log, console.warn, console.error, console.debug, and console.info calls from the view's JavaScript context.
Always register this during development. JavaScript errors are otherwise silent. The callback fires on the main thread.
api->RegisterConsoleCallback(view,
[](PrismaView, PRISMA_UI_API::ConsoleMessageLevel lvl, const char* msg) {
const char* tag = lvl == PRISMA_UI_API::ConsoleMessageLevel::Error ? "[JS ERR] " :
lvl == PRISMA_UI_API::ConsoleMessageLevel::Warning ? "[JS WARN]" :
"[JS LOG] ";
logger::info("{} {}", tag, msg);
});
Translations
RegisterTranslations
virtual void RegisterTranslations(
PrismaView view,
const char* pluginName
) noexcept = 0;
Loads a Fallout 4 translation file for the view and injects window.L10N and window.t() into the page's JavaScript context on every load.
After calling this, every page load for that view will run the equivalent of:
window.L10N = { "$CLOSE": "Close", "$TITLE": "My Menu", ... };
window.t = function(k) { return window.L10N[k] !== undefined ? window.L10N[k] : k; };
window.t("$CLOSE") returns the translated string, or the key itself if no translation is found.
Call timing: Call after CreateView. Translations are applied on the next page load and on all subsequent reloads. If the page has already finished loading when you call this, translations are injected immediately.
Thread safety: Safe to call from the main thread.
Example:
// C++
g_view = g_api->CreateView("mymenu.html", OnDomReady);
g_api->RegisterTranslations(g_view, "MyPlugin_F4");
g_api->Hide(g_view);
// JS (mymenu.html)
document.getElementById('close-btn').textContent = window.t('$CLOSE');
document.getElementById('title').textContent = window.t('$MY_MENU_TITLE');
See Translations for the file format and folder location.
Typical Call Sequence
kGameDataReady:
RequestPluginAPI<IVPrismaUI3>() → get api
KeyHandler::RegisterSink()
KeyHandler::Register(key, Toggle)
kPostLoadGame / kNewGame:
api->CreateView("page.html", OnDomReady) → g_view
api->RegisterTranslations(g_view, "MyPlugin_F4") // optional
api->RegisterConsoleCallback(g_view, ...)
api->Hide(g_view)
OnDomReady:
api->RegisterJSListener(g_view, "jsFunc", cppCallback)
api->Invoke(g_view, "initPage()")
Toggle (key press):
if visible:
api->Show(g_view)
api->Focus(g_view, pauseGame, false)
else:
api->Unfocus(g_view)
api->Hide(g_view)
PrismaUI F4 supports the standard Fallout 4 translation file format. Call RegisterTranslations once after CreateView and the framework handles everything else — detecting the game language, loading the right file, and injecting window.L10N / window.t() into your page's JS context on every load.
File format
Translation files use the same format Bethesda uses for vanilla Fallout 4 UI strings:
- Encoding: UTF-16 LE with BOM (
FF FE) - One entry per line:
$KEY<tab>Translated string - Keys start with
$ - Lines that don't start with
$are ignored (use them for comments)
$CLOSE Close
$MY_MENU_TITLE My Menu
$ITEM_COUNT Items: {0}
UTF-8 files (no BOM) are also accepted for convenience during development, but the game's own tools produce UTF-16 LE, so use that for release.
File location
Data\Interface\Translations\<PluginName>_<lang>.txt
Full example for English:
Data\Interface\Translations\MyPlugin_F4_en.txt
The framework looks up <PluginName>_<lang>.txt first. If that file is missing and the language is not English, it falls back to <PluginName>_en.txt.
Language codes
Language detection
The framework reads sLanguage from the player's INI files in this priority order:
%USERPROFILE%\Documents\My Games\Fallout4\Fallout4Custom.ini%USERPROFILE%\Documents\My Games\Fallout4\Fallout4Prefs.ini<GameDir>\Fallout4.ini
The first file that contains sLanguage under [General] wins. This matches how the game itself picks a language.
C++ setup
// kPostLoadGame / kNewGame:
g_view = g_api->CreateView("mymenu.html", OnDomReady);
g_api->RegisterTranslations(g_view, "MyPlugin_F4");
g_api->Hide(g_view);
That's all. The framework detects the language, loads the file, and re-injects translations automatically every time the page reloads.
JavaScript usage
After RegisterTranslations, every page load has access to two globals:
// Look up a key — returns the translated string, or the key if not found
window.t('$CLOSE') // → "Close"
window.t('$MY_MENU_TITLE') // → "My Menu"
window.t('$MISSING_KEY') // → "$MISSING_KEY" (key returned as-is)
// The raw lookup table if you need direct access
window.L10N['$CLOSE'] // → "Close"
window.t is available from the moment window exists — before DOMContentLoaded, before any <script> tags execute. You can use it directly in inline scripts:
<script>
document.getElementById('close-btn').textContent = window.t('$CLOSE');
</script>
Or in a framework component:
// React / Vue / plain JS — all work the same
const label = window.t('$CONFIRM');
Example mod folder layout
mods/MyPlugin_F4/
├── F4SE/Plugins/
│ └── MyPlugin_F4.dll
├── PrismaUI_F4/
│ └── views/
│ └── mymenu.html
└── Interface/
└── Translations/
├── MyPlugin_F4_en.txt
├── MyPlugin_F4_de.txt
└── MyPlugin_F4_fr.txt
Notes
- If no translation file is found for the detected language and no English fallback exists,
window.L10Nis not injected.window.twill not be defined. Guard against this if translations are optional:const t = window.t ?? (k => k); - Keys are case-sensitive.
$Closeand$CLOSEare different keys. - Values can contain any characters including HTML — escape them yourself before inserting into
innerHTML.
The Runtime
Views are rendered by Ultralight, an embeddable WebKit-based browser engine. The WebKit version shipped with this framework is roughly equivalent to Safari ~2020 / Chrome ~80. It supports modern CSS and ES2020 JavaScript, but it is not a full browser — certain APIs that exist in Chrome or Firefox are absent or behave differently.
When something doesn't work, check this document before assuming your HTML is wrong.
File Location
The framework loads views via file:/// URIs resolved relative to Data/PrismaUI_F4/views/. Filenames must be unique across all PrismaUI plugins. You can reference images or other assets by relative path from the views folder.
Never edit files under mods/ directly. Always edit the source and redeploy with Copy-Item.
JavaScript Support
What Works
- ES2020+:
const,let, arrow functions, template literals, destructuring,async/await, Promises,class, optional chaining (?.), nullish coalescing (??) - DOM API: Full access to
document,window,Element, event listeners,setTimeout/setInterval,requestAnimationFrame - Fetch API: Available for
file://resources. Not useful for network requests (game process has no internet access by design). - CSS: Flexbox, Grid, CSS variables (
--var), animations (@keyframes), transitions,calc(),backdrop-filter,clip-path - Web Storage:
localStorageandsessionStorageare available but data is scoped to the view's URL. Not persistent across game launches. - JSON:
JSON.parse/JSON.stringifywork normally. - Canvas: 2D canvas API available.
- SVG: Inline SVG in HTML renders correctly.
What Does NOT Work
Always Guard Optional APIs
// WRONG — will throw ReferenceError and abort the entire script
var observer = new IntersectionObserver(callback);
// CORRECT
var observer;
if (typeof IntersectionObserver !== 'undefined') {
observer = new IntersectionObserver(callback);
} else {
observer = { observe: function() {}, unobserve: function() {} };
console.log('IntersectionObserver not available');
}
A ReferenceError at script load time aborts the rest of your JS. Always test features before using them.
JavaScript Console Logging
Use console.log() — never dbg(), print(), or custom globals.
console.log('info message'); // appears in C++ log as [JS LOG]
console.warn('warning'); // appears as [JS WARN]
console.error('error'); // appears as [JS ERR]
These only appear in your F4SE log if you registered a ConsoleMessageCallback from C++. See the API Reference.
The JS ↔ C++ Bridge
C++ → JS: Push data to the page
Use InteropCall for named function calls (best performance):
// C++
api->InteropCall(view, "onPlayerData", R"({"hp":210,"ap":75,"name":"Sole Survivor"})");
// JS
function onPlayerData(json) {
var data = JSON.parse(json);
document.getElementById('hp').textContent = data.hp;
}
Use Invoke for arbitrary expressions:
// C++
std::string script = "document.title = '" + escapedTitle + "'";
api->Invoke(view, script.c_str());
JS → C++: Events from the page to the plugin
Register a listener from C++ (do this inside your OnDomReady callback):
// C++
api->RegisterJSListener(view, "requestClose", [](const char* /*arg*/) {
api->Unfocus(view);
api->Hide(view);
});
api->RegisterJSListener(view, "onSettingChanged", [](const char* json) {
// json is whatever JS passed as the first argument
// parse it and apply the setting in-game
logger::info("Setting changed: {}", json);
});
// JS — these are now global functions on window
document.getElementById('closeBtn').addEventListener('click', function() {
requestClose();
});
document.getElementById('volumeSlider').addEventListener('input', function() {
onSettingChanged(JSON.stringify({ key: 'volume', value: this.value }));
});
Passing Complex Data
Always use JSON strings for structured data crossing the C++/JS boundary. Both sides parse and produce JSON:
// C++ — build JSON and push it
nlohmann::json j;
j["items"] = nlohmann::json::array();
for (auto& item : inventory) {
j["items"].push_back({ {"name", item.name}, {"count", item.count} });
}
api->InteropCall(view, "loadInventory", j.dump().c_str());
// JS — receive and render
function loadInventory(jsonStr) {
var data = JSON.parse(jsonStr);
data.items.forEach(function(item) {
// build DOM nodes...
});
}
Viewport and Layout
Views are always the full screen size. 100vw and 100vh equal the game's render resolution. Design your UI to work at 1920×1080 and test at 2560×1440 if possible. Use min-width, max-width, and centered containers rather than fixed pixel positions.
/* Good — centered container, responsive width */
.panel {
width: min(600px, 90vw);
margin: 0 auto;
}
/* Risky — will look wrong at 4K */
.panel {
left: 660px;
width: 600px;
}
Closing the View from JS
The standard pattern is to register a listener and call it:
// JS
function closeMyMenu() {
requestClose(); // listener registered from C++
}
Alternatively you can call a JS function that the C++ side reads back via Invoke with a callback, but the listener pattern is simpler and lower latency.
Transparency and Backgrounds
The view composites over the 3D game using alpha blending. background:transparent on body will show the game world through your page. You can create floating panels with semi-transparent backgrounds:
body {
background: transparent;
}
.panel {
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px); /* blurs game world behind panel */
}
If you want to block mouse clicks from reaching the game (e.g., a full-screen overlay), set pointer-events: auto on a full-screen element. The focus system handles this at the engine level — when focused, all mouse events go to the view regardless.
Fonts
System fonts available in the game process: Courier New, Arial, Segoe UI, Consolas. For a terminal/retro look, Courier New or Consolas are reliable. You can embed a font as base64 in a @font-face rule if needed, but avoid loading fonts from external URLs.
Performance Guidelines
- Avoid
document.querySelectorAllin tight loops. Cache element references. - Batch DOM updates — build HTML strings and set
innerHTMLonce rather than appending many nodes individually. requestAnimationFrameis available and works correctly for smooth animations.- Heavy JS work (sorting large arrays, string manipulation) is fine. The Ultralight thread is dedicated and won't block the game's render thread.
- Avoid creating and destroying many DOM nodes repeatedly. Reuse and update existing nodes.
Debugging
Register a ConsoleMessageCallback from C++ to see all console.* output in your F4SE log. This is the primary debugging tool.
For DOM inspection, use the inspector:
// C++ — create and show inspector (development only, never ship this)
api->CreateInspectorView(view);
api->SetInspectorBounds(view, 10.0f, 10.0f, 900, 600);
api->SetInspectorVisibility(view, true);
The inspector is the full WebKit DevTools. You can inspect the DOM, run JS in the console, check computed styles, and see network requests (all file://).
Template: Minimal Full-Screen Menu
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
width:100vw; height:100vh;
background:transparent;
font-family:'Courier New',monospace;
display:flex; align-items:center; justify-content:center;
}
.panel {
background:rgba(8,8,6,0.92);
border:1px solid #3d3208;
padding:32px 40px;
color:#f59e0b;
min-width:400px;
}
</style>
</head>
<body>
<div class="panel">
<h1 id="title">MENU</h1>
</div>
<script>
// If RegisterTranslations was called from C++, window.t is available here
// document.getElementById('title').textContent = window.t('$MENU_TITLE');
// Data pushed from C++ via InteropCall
function setTitle(text) {
document.getElementById('title').textContent = text;
}
console.log('page ready');
</script>
</body>
</html>
States
A PrismaUI view moves through these states:
[not created]
│
│ CreateView("page.html", onDomReady)
▼
[loading] ← Ultralight is fetching and parsing the HTML
│
│ DOM parsed and JS executed
▼
[ready + visible] ← default state after creation (call Hide immediately!)
│
│ Hide()
▼
[ready + hidden] ← typical idle state for toggle menus
│
│ Show()
▼
[visible + no focus] ← rendered on screen, game input unchanged
│
│ Focus(view, pauseGame, disableFocusMenu)
▼
[visible + focused] ← mouse/keyboard routed to HTML, cursor shown
│
│ Unfocus()
▼
[visible + no focus]
│
│ Hide()
▼
[ready + hidden]
│
│ Destroy() (optional — rarely needed)
▼
[destroyed]
Creation
PrismaView view = api->CreateView("page.html", OnDomReady);
api->RegisterTranslations(view, "MyPlugin_F4"); // optional — load translation strings
api->Hide(view); // views start VISIBLE by default — always hide immediately after creation
CreateView is asynchronous — the HTML file is loaded on the Ultralight thread. Your OnDomReady callback fires on the main thread (via F4SE::GetTaskInterface()->AddTask) after the DOM is parsed and all inline <script> blocks have executed.
Do not call Invoke before OnDomReady fires. The JS context is not yet ready. RegisterJSListener is safe to call immediately after CreateView — it registers a C++ callback and does not touch the JS context.
Create views on kPostLoadGame / kNewGame, not on kGameDataReady. Example:
case F4SE::MessagingInterface::kPostLoadGame:
case F4SE::MessagingInterface::kNewGame:
if (g_view == 0 && g_api) CreateMyViews();
break;
Guard with g_view == 0 to avoid creating duplicates on multiple load events.
DOM Ready Callback
static void OnDomReady(PrismaView view)
{
// Safe to call JS operations here:
g_api->RegisterJSListener(view, "onClose", OnClose);
g_api->RegisterJSListener(view, "onDataRequest", OnDataRequest);
g_api->Invoke(view, "init()");
logger::info("DOM ready for view {}", view);
}
The callback receives the view handle so you can use one function for multiple views.
Show / Hide
Show and Hide control compositing — whether the view's pixels are included in the D3D11 Present call. They are not the same as Focus/Unfocus.
Typical toggle pattern:
static void Toggle()
{
if (!g_api || !g_api->IsValid(g_view)) return;
g_visible = !g_visible;
if (g_visible) {
g_api->Show(g_view);
g_api->Focus(g_view, /*pauseGame=*/true, /*disableFocusMenu=*/false);
} else {
g_api->Unfocus(g_view);
g_api->Hide(g_view);
}
}
Focus
pauseGame
When true, the game's time scale is set to zero — NPCs stop moving, timers pause, projectiles freeze. Restored automatically on Unfocus. Use for menus where the player needs to interact without danger (inventory, settings, terminal).
When false, the game continues running while the UI is open. Use for HUDs or overlays that don't require exclusive attention.
disableFocusMenu
Normally false. The FocusMenu is a Scaleform overlay the framework uses to route the game cursor to the HTML view. Setting this to true suppresses it, which can cause cursor visibility issues. Leave it false unless you have a specific technical reason.
HasAnyActiveFocus
if (api->HasAnyActiveFocus()) {
// Suppress game hotkeys while any PrismaUI menu is open
return;
}
Use this in your hotkey handler to prevent accidental game actions (drawing weapons, etc.) while a menu is open.
Multiple Views
Each call to CreateView produces an independent view with its own Ultralight context, D3D11 textures, and JS environment. Views do not share state.
Ordering: Views are composited in ascending order value. Default order is 0. If two views overlap, set the one that should appear on top to a higher order:
api->SetOrder(backgroundView, 0);
api->SetOrder(popupView, 10);
Focus: Only one view can have focus at a time. Calling Focus on a second view while the first is focused will focus the second; the first loses focus.
Performance: Each active view costs GPU texture memory and Ultralight rendering time. Keep views hidden when not in use. Rendering is skipped for hidden views.
View Recovery
PrismaUI_F4 has an internal recovery system. If the Ultralight thread throws a structured exception (SEH) while processing a view, the framework marks that view for recovery and reloads it from its original URL. Recovery attempts are limited to prevent infinite loops.
When a view recovers:
- The page reloads from scratch — all JS state, DOM state, and in-memory data is lost
OnDomReadyfires again for the recovered view, the same as on first load- Any
RegisterJSListenercalls made before the crash are not automatically re-registered — re-register them insideOnDomReadyif they need to survive recovery
You do not need to implement recovery logic in your plugin. If a view is behaving strangely after a long game session, check your F4SE log for recovery messages.
Inspector
The Ultralight inspector is a WebKit DevTools interface. Use it during development to debug layout, run console queries, and inspect element styles.
// Setup (do once, do not ship to end users)
api->CreateInspectorView(view);
api->SetInspectorBounds(view, 0.0f, 0.0f, 900, 550);
// Toggle visibility
bool showing = api->IsInspectorVisible(view);
api->SetInspectorVisibility(view, !showing);
The inspector renders as an overlay at the position and size you specify. You can interact with it using the mouse while it's visible. The main view is still rendered beneath it.
Do not ship CreateInspectorView calls in released mods. Wrap them in a debug flag or #ifdef:
#ifdef PRISMA_DEBUG
api->CreateInspectorView(g_view);
api->SetInspectorBounds(g_view, 10.0f, 10.0f, 900, 560);
api->SetInspectorVisibility(g_view, true);
#endif
Destruction
Destroy fully tears down a view. After calling it, the handle is invalid — do not use it again.
api->Unfocus(view);
api->Hide(view);
api->Destroy(view);
view = 0;
Destruction happens asynchronously on the Ultralight thread. Do not create a new view with the same filename immediately after destroying one; wait for the next kPostLoadGame event.
In normal usage you never need to destroy views — create them once on kPostLoadGame and keep them for the session.
Scroll
Mouse wheel events are forwarded to the focused view. The scroll amount in pixels per tick can be tuned:
api->SetScrollingPixelSize(view, 40); // faster scrolling
The default is 28 px per tick. Adjusting this only affects this view; other views are unaffected.
PrismaUI F4 is powered by Ultralight, an embeddable WebKit-based renderer. It is not a full browser. The following limitations apply to all views regardless of what you build.
Media
Video is not supported. The <video> element and all video playback APIs are unavailable. Use animated GIF images as a replacement for looping animations.
Audio is not supported. The Web Audio API and <audio> element do not function. Play sounds through F4SE instead — trigger audio from your C++ plugin in response to JavaScript callbacks.
WebGL is not supported. Canvas 2D works, but getContext('webgl') and getContext('webgl2') return null.
Rendering
PrismaUI F4 uses CPU rendering only. GPU-accelerated compositing is not available in the current release.
- UI refresh rate is capped at 60 FPS
- Heavy CSS operations cause real FPS impact. Use sparingly:
box-shadowandtext-shadowon large elementsfilter: blur(),backdrop-filter- Large
border-radiuson frequently-repainted elements - Heavy
background: linear-gradient()on animated elements
Keep your UI lightweight. Flat colours and simple transitions perform significantly better than visually complex designs.
JavaScript
- ES2022 and below only. Features introduced in ES2023+ may not work.
- WebKit version:
615.1.18.100.1 - For a full compatibility matrix, see the Ultralight Supported Web Features wiki page.
CSS Frameworks
If you use TailwindCSS, you must use v3. Tailwind v4 relies on browser features not available in Ultralight and will not work.
Operation Queue
Each view has an internal operation queue with a hard limit of 100 pending operations. Show, Hide, Focus, Unfocus, and similar calls each occupy one slot. If the queue fills up, further operations are silently dropped with an error in the F4SE log.
In normal usage this limit is never reached. It becomes relevant only if you are calling Show/Hide/Focus in a tight loop faster than the Ultralight thread can process them — for example, toggling a view every frame. Throttle such calls or gate them behind state checks (IsHidden, HasFocus) to avoid queuing redundant operations.
Multiple Views
The API supports multiple views per plugin. Each view has its own Ultralight context, D3D11 textures, and JS environment — they do not share state.
Where possible, prefer a single view that manages its own routing and page state internally (React Router, Vue Router, etc.). Multiple views are fine for genuinely separate surfaces like a persistent HUD alongside a toggle menu, but avoid creating many views for screens that could be JS routes inside one page. Each view adds texture memory and CPU rendering overhead.
Event Handling Quirks
Right-click / contextmenu
The contextmenu DOM event does not fire correctly. Implement right-click detection manually:
window.addEventListener('mousedown', (event) => {
if (event.button === 2) {
const contextMenuEvent = new MouseEvent('contextmenu', {
...event,
view: window,
bubbles: true,
cancelable: true,
screenX: event.pageX,
screenY: event.pageY,
clientX: event.pageX,
clientY: event.pageY,
});
event.target?.dispatchEvent(contextMenuEvent);
}
});
Blocking specific keys from inputs
Numpad keys and other game-bound keys may type characters into focused <input> elements. Block them with a combined keydown + beforeinput listener:
const BLOCKED_KEY_CODES = [
96, 97, 98, 99, 100, // Numpad 0-4
101, 102, 103, 104, 105, // Numpad 5-9
];
let lastKeyCode = null;
window.addEventListener('keydown', (e) => {
lastKeyCode = e.keyCode;
}, { capture: true });
window.addEventListener('beforeinput', (e) => {
if (lastKeyCode !== null && BLOCKED_KEY_CODES.includes(lastKeyCode)) {
e.preventDefault();
}
}, { capture: true });
Custom Cursor
Replace the default system cursor with a custom PNG image when any PrismaUI view has focus. Place your file at:
Data/PrismaUI_F4/misc/cursor.png
In MO2, inside your mod folder:
mods/MyPlugin_F4/
└── PrismaUI_F4/
└── misc/
└── cursor.png
No code changes required. PrismaUI F4 automatically uses the file when a view is focused. Use a PNG that is visible against varying in-game backgrounds.