Skip to content

← All entries

07

· 5 min read

Web page inside Unreal

UICEFMENUS

I sat down to build the strain editor screen and decided not to use UMG.

Yesterday’s entry signed off with “no WBP for it exists yet, that’s tomorrow.” Tomorrow turned out to be a full-day spelunk through Unreal’s web browser plugin and a strain editor that is, technically, an HTML page.

I had a high-fidelity HTML mock of the screen sitting in my downloads. Caveat for the display font, JetBrains Mono for the labels, a paper-grain SVG noise texture, hard offset shadows, the period after “The collection.” sitting in --accent red. The plan, an hour into the morning, was to rebuild all of it in UMG. The plan after another hour was to not do that. UMG would lose the texture and take me three days. The design was already a working web page. Unreal ships a UWebBrowser widget. Why not just host the page.

This was a Future Me problem, and I owe Past Me an apology.

The widget loaded fine. The page loaded. The CSS resolved. Babel ran. React rendered. Nothing could talk to Unreal. Which is the whole point of doing this, you bind a UObject from the C++ side and JS calls its methods.

Things I learned about CEF in UE 5.7, in the order I learned them, from log lines and engine source:

  • Loading the JSX off disk via <script type="text/babel" src="strain-editor.jsx"> quietly fails under file:// because CEF blocks the XHR Babel uses to fetch it. The page renders empty and the only complaint is buried in a console message. Fix: read the JSX off disk in C++, splice it into the HTML string, hand the whole document to LoadString.
  • The “permanent” binding flag on SWebBrowser::BindUObject doesn’t actually survive the first navigation in 5.7. The base UWebBrowser doesn’t expose the OnLoadStarted hook the engine’s own comments say you need. I subclassed it and override RebuildWidget to wire OnLoadStarted in by hand, then re-install every binding every time a page loads.
  • The bound object does not appear at window.<Name>. It appears at window.ue.<Name>. This is not in the docs. The clue was a single line in CEFWebBrowserWindow.cpp dispatching a ue:ready event whose detail was window.ue.
  • Method names may be lowered depending on a config bool I haven’t tracked down. So bridge.Ping might be bridge.ping and the JS has to walk the object case-insensitively.
  • The methods return Promises, even when the C++ side is synchronous. await everything.

Each of those was an hour. Each fix needed a Live Coding patch, which lives behind Ctrl+Alt+F11 and only works if I haven’t already crashed the editor by being impatient with the previous one.

Around 5pm the page loaded, the binding installed, the catalogue marshaled across, and the design appeared in the editor. The Caveat heading. The four category rows in their proper colors. The dot at the end of “The collection.” sitting in the right red. The paper grain. The page looked like the mock because it was the mock. I sat there for a second.

The rest of the work fell out fast. Continue does what it should now: camera fade cleared, input restored, next night begins. The cave hiding spot resets itself on OnNightStarted so the bat can roost again. Starter cards seed on first play so the screen has something to show. The mouse cursor shows up (a separate fight I have notes on).

What’s still loose. The web page pulls React, Babel, and Google Fonts from CDN. Fine for now, has to be bundled before ship. CEF is also being quietly sunsetted in Unreal, so the long-term answer is Ultralight, which renders HTML faster and is built for games. The JSX and CSS port over unchanged. Only the host widget needs to be swapped.

Anyway. The strain editor exists. The design landed in the engine without anyone redrawing it. I wrote a skill that captures the whole pattern. Future me, please read it before the next screen.


Later that evening.

Future me read it. Three more screens landed by sundown.

The handoff zip had three artboards in it: a main menu, a pause overlay, and a “Caught” game-over screen. The deal I made with myself was to follow the skill, treat the strain editor as the reference, and not learn anything new. That mostly held.

Three things still surprised me.

First, the centered 1920x1080 sheet slid off toward the bottom-right at any monitor that isn’t exactly 1080p. The scaler had transform-origin: 0 0, so the JS-computed non-1.0 scale grew or shrank the box from its top-left while the flex parent had already centered the unscaled version. Origin needed to be center center. Embarrassed it took me as long as it did.

Second, the pause overlay was supposed to render transparent so the dimmed gameplay shows behind the plate. CEF in 5.7 isn’t constructed with bSupportsTransparency=true, so “transparent” reads as white. Two white bars down the sides of the sheet at every aspect ratio wider than 16:9. I extended the paper-cream radial to fill the body and moved on. The “right” fix is a small C++ tweak to UEchoWebBrowser::RebuildWidget. I have a note for it.

Third, the daytime report had the same letterbox problem with a slightly different shade of dark behind it. Whoever wrote #0c0a07 and whoever wrote #161310 was the same person. Matched them up.

The main menu got its own GameMode. AEchoFrontendGameMode is a stand-alone AGameModeBase subclass that lives in the frontend level and only has one job: spawn WBP_MainMenuRoost, bind its delegates, and call OpenLevel into the gameplay map. Not AEchoGameModeBase. That one carries the entire night state machine, and I do not want the night state machine ticking behind a static menu. The two GameModes are independent. The frontend disappears the moment we travel into a gameplay map and BP_EchoGame takes over.

Continue is the part I’m pleased with. The bat could end up roosting in any of ten levels by the campaign’s tail, so “Continue the night” can’t point at a hard-coded TSoftObjectPtr. I added a LastPlayedLevel field to UEcholocationSaveGame. AEchoGameModeBase::BuildSaveState writes the current world’s package path on EndNight, stripping the PIE prefix so editor-saved slots still resolve in a packaged build. The frontend reads it back on Continue and travels there. Null falls back to FirstLevel. The menu’s “Continue the night” row disables itself when no save exists, so the dead branch is closed at the JS layer too.

Pause was a tiny lift. The player controller already had a MenuAction bound to a stubbed handler. I gave it a TSubclassOf<UPauseWebUI> slot, a live widget pointer, a TogglePauseMenu entry point, and an OnResume binding so the controller’s handle clears when the widget self-dismisses. Press Esc, world pauses, widget shows. Press Esc again, world resumes.

The menu also used to say “Begin a new night” with a hover dot rendered under each row like a tiny green underline. I moved the dot to the left of each item and the text now says “Begin a new story”. The bat has earned a longer noun.

Anyway. The skill held up. The bug surface moved from “how does CEF talk to Unreal” to “did I remember to set transform-origin: center”, which is the right kind of moving.