Postmortem

The first travel is hard, and every one after it is seamless

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

I got the same question wrong twice, in opposite directions, on a small Unreal 5.8 co-op prototype built on Epic Online Services.

PostUnreal has two online stacks. I picked the one that wasn't ready.A packaged Windows build of my Unreal EOS lobby project failed at sign-in while the same code ran fine on macOS, traced to a move assignment that drops one field. The fix was moving off the untested online stack entirely, and why nobody else hits this bug is the more interesting part.

The feature was ordinary: a party waits together in a holding map, travels together into the game map, and comes back the same way when one player presses a button. Getting the party to move together cost nothing — Unreal does that for free. Getting the travel itself right took under two hours by my own commit log for that evening, start to finish — because ServerTravel has two modes, they want opposite things depending on which hop you’re doing, and my first fix for one of them quietly broke the other.

This post is about what each mode actually does at the engine level, why the two travels in this flow need different ones, and the more interesting failure underneath it — a correct objection answered by deleting the thing it was pointing at, instead of narrowing it.

The first break — everything seamless

The plan going in was simple: seamless travel everywhere, because Epic’s own guidance recommends it whenever possible. Pressing “Create Room” called ServerTravel with ?listen; bUseSeamlessTravel was set true on the GameMode at the time, so the engine still carried the whole thing out seamlessly, to turn a standalone process into a listen server.

The result was a black screen with the lobby UI still stuck on top of it. No client was even involved — this failed on a single, unconnected process.

The cause is a single call site in this flow. ?listen is what actually turns a process into a server, and UEngine::LoadMap is the only code in the ServerTravel path that acts on it to open the port:

// UnrealEngine.cpp:16564-16566 (engine source, excerpt)
if (Pending == NULL && (!GIsClient || URL.HasOption(TEXT("Listen"))))
{
    if (!WorldContext.World()->Listen(URL))

FSeamlessTravelHandler never reaches LoadMap. Its CopyWorldData step only carries forward a NetDriver that already exists:

// World.cpp:8535-8536 (engine source, excerpt)
UNetDriver* const NetDriver = CurrentWorld->GetNetDriver();
LoadedWorld->SetNetDriver(NetDriver);

Out of a standalone world, that NetDriver is null. A seamless travel with ?listen reports success and hosts nothing — there’s no error, because from the engine’s point of view nothing went wrong. And the black frame wasn’t a rendering bug either: it’s the bare world FSeamlessTravelHandler builds when TransitionMap is unset (World.cpp:8370), which it was at that point.

The fix, and the second break — everything hard

The fix looked obvious from there: seamless travel can’t create a server, so make the hosting travel hard — ?listen?NoSeamlessTravel. What I actually shipped made every travel hard, not only that one — and that wasn’t an oversight. It’s the more interesting failure, and it gets its own section below.

That built a server correctly. It also dropped every client on the second travel, the walk from the waiting map into the game map. Whoever pressed the button, only the host actually moved; every client fell back to its own lobby screen and vanished from the host’s party list. Catching that at all needed a second process actually joined and watching when the button was pressed — the same requirement that later let a differently-shaped version of this same flow slip through unnoticed.

PostDriving Unreal from the command lineA multiplayer host-and-join flow that needs two people at two keyboards to test let a bug through twice, in two different shapes, before I built a command-line pipeline that runs it unattended — generating, editing, packaging, and driving Unreal without opening the editor.

Hard travel disconnects clients on purpose. UEngine::Browse tears the client’s NetDriver down and rebuilds a fresh UPendingNetGame to redial (UnrealEngine.cpp:15803-15810).

Epic’s own documentation says this outright — hard travel means “the client will disconnect from the server and then re-connect” — and recommends seamless travel because it will “avoid any issues that can occur during the reconnection process.” Over EOS’s P2P transport, that redial just didn’t survive.

So: seamless travel can’t create a server. Hard travel disconnects every client except the host running it. Neither mechanism is wrong — each is wrong for one of the two hops in this flow.

A two-by-two decision matrix: travel mode (hard vs seamless) against world state (no NetDriver yet vs a NetDriver already present). Hard with no NetDriver: UEngine::LoadMap sees ?listen and calls World->Listen (UnrealEngine.cpp:16564-16566), a server is created — succeeds, used for the hosting travel, hop 1. Seamless with no NetDriver: FSeamlessTravelHandler::CopyWorldData only carries an existing NetDriver forward (World.cpp:8535-8536), none exists yet so it stays null — fails, the black-screen break. Hard with a NetDriver present: UEngine::Browse tears it down and rebuilds a fresh UPendingNetGame to redial (UnrealEngine.cpp:15803-15810) — fails, the second break. Seamless with a NetDriver present: CopyWorldData carries it forward unchanged (World.cpp:8535-8536) — succeeds, used for the round-start travel, hop 2. A note below states AGameModeBase::ProcessServerTravel reads bUseSeamlessTravel off the GameMode by default but a URL option overrides it per call (GameModeBase.cpp:488, 493-501), and a closing note states cell size carries no measurement and the matrix covers only the two hops exercised in this prototype, timed under two hours commit to commit — a third or fourth hop is untested

The two failures are mirror images of each other: one mode can create a server but breaks an existing connection to do it, the other keeps every connection intact but never reaches the one call that creates a server in the first place.

The actual fix

AGameModeBase::ProcessServerTravel reads bUseSeamlessTravel off this — the GameMode doing the travelling — but a URL option overrides it per call:

// GameModeBase.cpp:488, 493-501 (engine source, excerpt)
bool bSeamless = (bUseSeamlessTravel && GetWorld()->TimeSeconds < 172800.0f);
...
// Override based on URL parameters
if (NextURL.HasOption(TEXT("SeamlessTravel")))
{
    bSeamless = true;
}
else if (NextURL.HasOption(TEXT("NoSeamlessTravel")))
{
    bSeamless = false;
}

(The TimeSeconds < 172800.0f clause is an unrelated 48-hour overflow guard already built into the engine default — not part of this decision, and not something I added.)

That per-call override is the whole answer. The waiting room’s GameMode does both kinds of travel — it hosts, and later it starts a round — so a flag set once on the class can’t describe it. The hosting travel goes out with ?listen?NoSeamlessTravel. The round-start travel decides based on what’s actually true of the world at that moment:

// GameMode.cpp (excerpt)
const bool bHasNetDriver = World->GetNetDriver() != nullptr;
const FString TravelURL = FString::Printf(TEXT("%s%s"), LevelPath,
    bHasNetDriver ? TEXT("?SeamlessTravel") : TEXT("?NoSeamlessTravel"));

World->ServerTravel(TravelURL, /*bAbsolute=*/false);

I also had to stop leaving TransitionMap unset. Seamless travel can’t hold two worlds at once, so it passes through a third one — with nothing configured, the engine substitutes the bare world that was the black frame in the first place. One map with a single directional light in it and nobody spawning there removes it.

The party itself needed no project code at all. GetSeamlessTravelActorList always appends GameState->PlayerArray (GameModeBase.cpp:546), so every PlayerState — position in the roster, whatever data I’d attached to it — survives a seamless hop without me writing anything. What doesn’t survive is the APlayerController: a new one is spawned per client and the connection is reparented onto it (GameModeBase.cpp:607-641), which is a real constraint if you’re keeping UI state on the controller rather than a ULocalPlayerSubsystem.

The part that actually cost the time

The engine facts above were straightforward once I went looking for them. The under-two-hours figure up top covers this whole arc — deciding to try seamless, watching it fail to host, swinging to hard everywhere, watching that drop every client, and landing on the split that actually works, timed commit to commit. What ate most of that time was reasoning, not knowledge.

The shape that eventually shipped — hard for the travel that creates the server, seamless for everything after — had already gone into the same fix that made hosting work, before I committed it. And I talked myself back out of it. Seamless travel had just caused the black-screen bug, so why was seamless still anywhere in the code? The objection was correct: there was an inconsistency between the two ServerTravel calls, and it deserved an answer.

The answer I gave was to delete seamless travel entirely and make both hops hard, and that’s the version that actually got committed. That produced the second break — every client dropped on the round-start travel — because it treated “seamless travel broke the hosting hop” and “seamless travel is wrong everywhere” as the same claim. They aren’t. The correct response to “why is this mechanism still here after it broke something” is to work out exactly where it applies, not to remove it and see what else stops working.

I don’t think this is specific to travel modes. It’s the same failure shape as reading a linter complaint about one line and turning off the rule project-wide, or reading one flaky test and deleting the assertion instead of finding the race under it. The fix that actually holds up is almost always narrower than the objection makes it sound, and figuring out how much narrower is the part that takes the time.

What I didn’t verify

A few things from the investigation stayed open rather than getting chased down, because none of them is load-bearing for a three-map prototype:

  • I only exercised two hops — into the game map and back. Whether a third or fourth travel behaves the same way is untested.
  • The hosting travel is hard by the recommendation above, and hard travel is also a blocking call — Epic’s own docs put it plainly: “seamless travel is a non-blocking operation, while non-seamless will be a blocking call.” Nothing here puts anything on screen for that blocking span. It stayed short enough not to matter across two small maps, but a loading screen for it is still on my list, not in the code.
  • Nothing in this flow tells the EOS lobby that the map changed. The lobby lives on the GameInstance, outside any UWorld, so travel doesn’t touch it either way — fine for what the prototype does today, but it means the lobby’s advertised state and the actual map can drift apart if something later depends on that.
  • Later work on this same prototype added a readiness check so a travelling party can’t be placed into a map before its contents exist. That check asks only the server whether it’s ready and applies the answer to every client alike — a client whose own streaming lags behind the server’s is a different case, and it matches a community-reported failure mode where a client falls through a map it hasn’t finished loading. Also still open as far as I’ve taken this.

Wrap-up

What changed on my end isn’t the travel code, it’s what I do before I touch a flag that’s just been blamed for a bug: write down the single case that failed — hosting travel, seamless, no NetDriver yet — as a line next to the call site, before reaching for the switch. That line is what an objection to delete the mechanism has to argue against, instead of the memory of one black screen.

Nothing enforces that line getting written. It’s a habit, not a rule, and the two gaps still open above are the actual test of whether it holds up past this prototype — the untested third and fourth hop, and the lobby that still doesn’t know when the map has changed. Neither is urgent at three maps and two players; both are exactly the kind of thing that stops being optional the moment this prototype’s scope outgrows what I’ve tested, and I won’t know whether the habit catches them in time until it’s asked to.

References