Postmortem

Unreal has two online stacks. I picked the one that wasn't ready.

  • Unreal Engine
  • Epic Online Services
  • Multiplayer
  • Networking
  • C++
  • Game Development
  • Failure

Unreal Engine ships two unrelated online APIs. I built an EOS lobby prototype on the newer one, and it cost me a day to find out why a packaged Windows build couldn’t sign in while the same code ran fine on macOS.

The bug itself is three lines. What’s interesting is how the failure was shaped — the parts of it that made it invisible to Epic, to the forums, and to me for as long as it was.

The project

It was a narrow ask on top of Unreal Engine 5.8.1 and Epic Online Services: host a lobby, list them, join one, walk around the same level. No combat, no progression — I’d been building that same shape of multiplayer on Unity with NGO and UGS across earlier prototypes, and this hobby project existed to find out what it cost on Unreal instead.

One thing came in cheap. APawn replicates by default and UCharacterMovementComponent carries client prediction and server correction, so a player walking around in front of another player took no project code at all. Service integration did not come in cheap, and that’s the part this post is about.

Two stacks, and the beta label is only in the documentation

Unreal carries two independent online APIs side by side:

v1v2
APIIOnlineSubsystemUE::Online
PluginsOnlineSubsystem, OnlineSubsystemEOSOnlineServices, OnlineServicesEOS, OnlineServicesEOSGS, OnlineServicesEpicCommon
Async shapeDelegate handlesTOnlineResult with .OnComplete()
LobbiesFolded into IOnlineSessionILobbies, separate from ISessions

I picked v2. It’s the newer API and the async shape is nicer. Nothing at the engine or plugin level marks it as anything other than the normal thing to reach for: OnlineSubsystemEOS.uplugin:12 sets "IsBetaVersion": false, and none of the six OnlineServices* plugins set the field at all — no beta flag, no experimental flag, no deprecation notice, nothing in the project template that says “pick the other one.”

Epic’s own documentation disagrees, and says so plainly. The Online Services overview page carries a banner directly under its header: “Learn to use this Beta feature, but use caution when shipping with it.” The page is tagged beta in its own metadata. Underneath that banner:

The Online Services plugins have not been tested in shipping titles

— wording that carries its “as of UE 5.1” phrasing forward into the current 5.8 docs, next to a direct recommendation to use Online Subsystem instead for “any title shipping in the near future.” None of that reaches the plugin manifest or the project template. It lives on one documentation page, and the signal the engine actually gives a developer choosing between the two — the plugin metadata — says nothing of the kind.

When I picked v2, the reasons had nothing to do with beta status either way: which stack Epic was investing in past the UE6 Blueprint-and-Actor deprecation announcement, and whether the Lobbies interface shape lined up with the UGS Lobby work I’d already done on Unity. Maturity wasn’t one of the inputs I weighed.

It worked on macOS

I built and tested on macOS through the whole prototype. Login, lobby creation, lobby search, joining — all of it worked. The first packaged Windows build failed at sign-in:

LogEOSSDK: Error: LogEOSAuth: Invalid parameter EOS_Auth_LoginOptions.LoginFlags
    reason: user interface is required by the EOS_LCT_AccountPortal login method
LogOnlineServices: Warning: [FAuthEOS::Login] Failure: LoginEASImpl [3.4.442] EOS_Auth_UserInterfaceRequired

The overlay wasn’t the problem — it loaded and initialised, and the rejection came back in 12 ms, which is parameter validation, not a runtime failure. Something was telling the SDK not to show a UI on a login method that requires one.

The three lines

FEOSAuthLoginOptionsCommon::operator= copies CredentialsData, ApiVersion and ScopeFlags. It never assigns LoginFlags (EOSAuthLoginOptionsCommon.cpp:63-90). The move constructor delegates to that same operator, and it never value-initialises the base EOS_Auth_LoginOptions — a plain C struct — before delegating. So after a move, LoginFlags holds whatever bit pattern was sitting on the stack.

The default constructor does zero it (:99), but the actual login path doesn’t go through the default constructor at the point that matters. AuthEOSGS.cpp:877 moves the options after they’ve already been built:

FEOSAuthLoginOptions LoginOptions = MoveTemp(LoginOptionsResult.GetOkValue());

EOS_LF_NO_USER_INTERFACE is 0x00001, the low bit. If the residual stack value happens to have that bit set, the SDK is told to skip the UI — on AccountPortal, the one login method that requires one.

That’s the entire platform difference. The code is identical on macOS and Windows. Clang and MSVC just leave different garbage on the stack at that call site, so the low bit came out clear on one and set on the other.

v1 doesn’t have this fault, for a boring reason: it zero-initialises the whole struct before filling it in (UserManagerEOS.cpp:876):

EOS_Auth_LoginOptions LoginOptions = { };

No move, no partial assignment, no uninitialised field. And FAuthLogin::Params — the public-facing parameter struct — exposes no LoginFlags field at all, so there’s no way to work around this from outside the engine.

Why nobody had reported it

LoginCredentialsType::Auto reads -AUTH_TYPE=, -AUTH_LOGIN= and -AUTH_PASSWORD= off the command line (AuthEOSGS.cpp:232-247). That’s exactly what the Epic Games Launcher passes when it starts a game — -AUTH_TYPE=exchangecode. ExchangeCode needs no UI, so the SDK never validates the UI requirement, and the corrupt LoginFlags value passes through completely harmlessly.

Shipping titles distribute through the Epic Games Launcher, which is what actually passes -AUTH_TYPE=exchangecode. AccountPortal is the development and fallback login method — and it’s also the only one available to a build that’s just a zip handed to a tester, with no Launcher session behind it. That’s the path a shipping title’s normal distribution flow doesn’t take, and a hobby prototype takes on day one.

Three separate things had to hold at once for the corrupted LoginFlags to surface as a rejected login. First, the field: of the four fields on EOS_Auth_LoginOptions, operator= at EOSAuthLoginOptionsCommon.cpp:63-90 leaves only LoginFlags unassigned — true by construction, not something that varies between runs. Second, the path: the login method the process resolves to must be one that reads LoginFlags at all — ExchangeCode, resolved when the Epic Games Launcher passes -AUTH_TYPE=exchangecode, never reads it; AccountPortal, the only method a bare zip handed to a tester can reach, does. Third, the value: bit 0 of the residual stack value, EOS_LF_NO_USER_INTERFACE (0x00001), must be 1 — not decided by this code, and in this build observed as 0 on macOS and 1 on Windows, a single observation on each platform. Only when all three are true does the SDK reject the login as EOS_Auth_UserInterfaceRequired in 12 milliseconds; if any one is false, sign-in proceeds instead.

The same interfaces have other reports on Epic’s forums that fit the same shape — a lobby created but not found by FindLobbies, attribute search not working, GetResolvedConnectString breaking in 5.7 — plus stub methods still returning NotImplemented in shipped code (AuthEOSGS.cpp:809-823, SessionsEOSGS.cpp:307-314, ExternalUIEOS.cpp:71, CommerceEOS.cpp:192). None of it reads as one unlucky bug. It reads as a stack that’s API-complete and genuinely untested along the axis that doesn’t matter to whoever tests it internally.

What I ruled out before rewriting

The engine fix is one line — LoginFlags = Other.LoginFlags; in the move assignment. I didn’t take it, because taking it means either a source-built engine on both macOS and Windows or vendoring an engine plugin into the project, to fix one defect in a stack whose own vendor says has not been tested in shipping titles. The next defect would need the next patch.

The EOS Developer Auth Tool sidesteps AccountPortal entirely during development, logging in without ever touching the broken flag. That’s real, but it only moves the problem: it fixes nothing for a build that’s already a zip in someone else’s hands, which was the case that mattered.

The rewrite was fast, on purpose

There’s no compatibility shim between the two stacks for EOS — OnlineServicesOSSAdapter runs the other direction, wrapping v1 to expose it through the v2 API for platforms with no native v2 backend, and no OnlineSubsystemEOSPlus exists in this engine tree. The interface hierarchies, id types, and async model all differ, so this was a rewrite, not a config flip.

It went fast anyway. The rewrite’s own commit history spans about two and a half hours, start to finish, and by the end of it a packaged Windows build was signing in. That was possible because v2 had never been allowed to leak: it lived entirely inside a single lobby subsystem class. The Slate UI, the character, the game mode, the player controller — none of them touched the online API directly, they all went through that one subsystem’s delegates. Keeping v2 confined there wasn’t done in anticipation of this exact failure. It just happened to be the boundary that made the failure cheap.

NetDriverEOS is shared between both stacks and already reads v1-shaped config, so replication itself didn’t move at all — P2P was never part of the choice.

What buying a plugin would and wouldn’t have fixed

Third-party plugins for EOS on Unreal exist, and I looked at them before committing to the rewrite. For what this project needed — host a lobby, list them, join one, walk around the same level — buying one wouldn’t have fixed anything: the defect was in v2’s own move assignment, and moving to stock v1 already removed it before a plugin would even enter the picture.

If I were building a real online service on Unreal instead of a narrow lobby prototype, I’d want one anyway — that’s a different job from the one this project had, and it’s not one a stack-level defect fix stands in for.

Wrap-up

The bug is three lines and a plausible one to write. What made it costly wasn’t the bug — it was that the two things that mattered were nowhere the failure would surface them: a beta warning that lives on one documentation page and nowhere in the plugin the engine actually loads, and the fact that “compiles against a complete API” and “has been exercised by a shipping title” are different claims that look identical from outside the engine.

The gap between those two states is invisible right up until you take the one code path that a shipping title’s launch flow makes structurally impossible to hit. A zip handed to a tester takes it on the first run.

Whether v2 is worth revisiting stays open. The defect is one line and Epic may fix it, but the actual blocker was never the defect — it was “not tested in shipping titles,” and nothing about this prototype tracks whether that line changes on a later engine release.

That question hasn’t reopened on its own, either. The v1 decision has carried forward, unrevisited, through several Unreal prototypes since this one, each one inheriting it rather than re-deciding it — not because v2 became safe, just because nothing has forced the question back open. For now the lobby stays on v1.

PostThe first travel is hard, and every one after it is seamlessPressing Create Room gave a black screen with nothing listening. Fixing that dropped every client on the next map change. The fix is deciding hard vs seamless per ServerTravel call, and the harder lesson was almost deleting that fix entirely.

References