59 minute read

I suppose you already have at least two levels in your game, where players are traveling from one level to the other. After players make it to the new level, they find out that the data they used to have in the old level is simply gone. In other scenarios, due to some harsh net conditions, players may be disconnected from the server and be reconnected back, which if not handled properly will result in a data loss too!

Introduction

In this post, we’ll touch on travel and disconnect (which is in fact an uninitiated travel) scenarios in Unreal Engine. More importantly, we’ll be discovering several ways to make our data persist over such scenarios. Furthermore, we’ll get to know how those ways differ from one another, and in what scenarios we should pick one over the other.

Travel: Seamless vs. Hard

In Unreal, there are two main ways to travel: Seamless and non-seamless which is also referred to as hard travel. The main difference, is that seamless travel is a non-blocking operation due to asynchronous level loading, while non-seamless will be a blocking call due to synchronous level loading. In addition, in non-seamless traveling, players are disconnected from the server and reconnect when the new map loads synchronously, while in seamless traveling, players stay connected but are transferred to an asynchronously loaded transition map, until the destination level has asynchronously loaded too.

Seamless travel should be favored over a non-seamless one when possible, as it results in a smoother experience generally, and avoids some other issues when the client reconnects.

There are three ways in which a non-seamless travel must occur:

  • When loading a map for the first time
  • When connecting to a server for the first time as a client (e.g. joining sessions)
  • When you want to end a multiplayer game, and start a new one

Note: The first way doesn’t mean loading every new map for the first time, but the first map the initial connection loads. Otherwise, that would mean, any new map we travel to, would end up to be a non-seamless travel, which is incorrect.

Travel Types

Now that we understand the ways of travel in Unreal, let’s understand the travel types, which can be seen in this enum class:

EngineBaseTypes.h

// Traveling from server to server.
UENUM()
enum ETravelType
{
    /**Absolute URL. */
    TRAVEL_Absolute,
    /** Partial (carry name, reset server).*/
    TRAVEL_Partial,
    /** Relative URL. */
    TRAVEL_Relative,
    TRAVEL_MAX,
};

Let’s explain each travel type and its intended use:

  • TRAVEL_Relative (same server, and last options string is kept): Current URL is relative to last URL , so we aren’t disconnected from the server, making it ideal for seamless travel. Therefore, this travel type is a requirement when seamless traveling on client. The last options string is carried over to the new level.
  • TRAVEL_Partial (server resets, though last options string is kept): Current URL is partial to last URL, so we are disconnected from the server, which corresponds to a non-seamless travel. The last options string is carried over to the new map.
  • TRAVEL_Absolute (server resets, and last options string is ignored): Current URL is absolute, meaning that last URL (including last options string) is flushed, so we are disconnected from the server, which corresponds to a non-seamless travel.

Here is a summary table, showing for each travel type what’s kept (last server URL, and last options string), and if compatible with seamless and hard travels:

Travel Type Keeps Last Server URL? Keeps Last Options String? Supported by Seamless Travel? Supported by Hard Travel?
Relative :heavy_check_mark: :heavy_check_mark: :heavy_check_mark: :heavy_check_mark:
Partial   :heavy_check_mark:   :heavy_check_mark:
Absolute       :heavy_check_mark:

Native Travel Drivers

There are three main native functions that drive traveling: UEngine::Browse(), UWorld::ServerTravel(), and APlayerController::ClientTravel(). These can be a bit confusing when trying to figure out which one to use, so here are some guidelines that should help:

UEngine::Browse

  • Is like a hard reset when loading a new map.
  • Will always result in a non-seamless travel.
  • Will result in the server disconnecting current clients before travelling to the destination map.
  • Clients will disconnect from current server.
  • Dedicated server cannot travel to other servers, so the map must be local (cannot be URL).

UWorld::ServerTravel

  • For the server only.
  • Will jump the server to a new world/level.
  • All connected clients will follow.
  • This is the way multiplayer games travel from map to map, and the server is the one in charge to call this function.
  • The server will call APlayerController::ClientTravel() for all client players that are connected.

APlayerController::ClientTravel

  • If called from a client, will travel to a new server.
  • If called from a server, will instruct the particular client to travel to the new map (but stay connected to the current server).

Blueprint Travel Drivers

In Blueprints, the two functions/nodes that drive traveling are:

OpenLevel

  • This function will always result in a hard travel (even if seamless travel is enabled.
    If called from client, will travel to a new server, without disconnecting other clients from the server. If called from a listen-server, will travel the listen-server player to a new map, disconnecting clients back to the entry/default map. If called from a dedicated-server, will result in a travel failure, disconnecting clients back to the default map, unless the travel URL has option “listen”, which will instruct the particular client to travel to the new map as a listen-server, however, other clients will disconnect back to the default map.
    It corresponds to the native function UGameplayStatics::OpenLevel(), which calls UEngine::SetClientTravel(). On the next tick, UGameEngine::Tick() calls UEngine::Browse(), which calls UEngine::LoadMap(). By default bAbsolute = true, making it an Absolute travel, otherwise, it’s Relative.

Caution: Testing traveling (and many more networking functionalities) in PIE should be avoided, as that can crash your editor, and won’t reflect how your code is going to function in realtime. Stick to traveling in a standalone/packaged game!

ExecuteConsoleCommand

  • This function and Command parameter be ServerTravel <MapName> or Travel <MapName>(the former is the one usually used as it can be seamless while the latter can’t) where they travel to the specified map, passing along previously set options string, as they are Relative and Partial travels respectively. They correspond to the native functions: UEngine::HandleServerTravelCommand() and UEngine::HandleTravelCommand() respectively. Another command that you can use is Open <MapName> which opens the specified map, without passing previously set options string, as it’s an Absolute travel. It corresponds to the native function UEngine::HandleOpenCommand(), which calls UEngine::SetClientTravel(). On the next tick, UGameEngine::Tick() calls UEngine::Browse(), which calls UEngine::LoadMap(). It’s always an Absolute travel.

Luckily those commands are autocompleted when typed inside the Console Command at runtime:

BaseInput.ini

+ManualAutoCompleteList=(Command="Open",Desc="<MapName> Opens the specified map, doesn't pass previously set options")
+ManualAutoCompleteList=(Command="Travel",Desc="<MapName> Travels to the specified map, passes along previously set options")
+ManualAutoCompleteList=(Command="ServerTravel",Desc="<MapName> Travels to the specified map and brings clients along, passes along previously set options")

Note: Console commands aren’t case sensitive, so they can be written as desired: All capital/small letters, PascalCase, camelCase, etc.

Enabling Seamless Travel and Transition Map

To enable seamless travel, you need to setup a transition map. This is configured through the UGameMapsSettings.TransitionMap property. By default this property is empty, and if your game leaves this property empty, an empty map will be created for the transition map.

The reason the transition map exists, is that there must always be a world loaded (which holds the map), so we can’t free the old map before loading the new one. Since maps can be very large, it would be a bad idea to have the old and new map in memory at the same time, so this is where the transition map comes in.

So now we can travel from the current map to the transition map, and then from there we can travel to the final map. Since the transition map is very small, it doesn’t add much extra overhead while it overlaps the current and final map.

Once you have the transition map setup, you set AGameModeBase.bUseSeamlessTravel to true, and from there seamless travel should work.

Note: Pre 5.1, seamless travel wasn’t supported in single process PIE (play-in-editor). Therefore, you had to either turn off Run Under One Process setting, or start a standalone (right click your .uproject and Launch game), or launch a packaged build. Since 5.1, seamless travel is supported in single process PIE, but you need to enable CVar net.AllowPIESeamlessTravel from console command as it’s disabled by default.

GameFramework Objects

It’s quite important to get to know the Unreal gameplay framework objects, their creation order, calls, and space. This will help us tackle a spectrum of issues, that not just necessarily related to persisting data.

Tip: From now on, you’ll quite often see AGameMode(Base) which is my way of saying either AGameModeBase or AGameMode, based on which you subclass. Generally speaking, you should favor AGameMode as it has support for much more functionalities: Match states, disconnection bookkeeping, true persistence of PlayerController and its dependency Actors, etc.

GameFramework Objects Creation Order and Calls

Most of the times, when we travel (regardless of the type) from one level to another, or when we disconnect and reconnect, Objects get destroyed and recreated (excluding GameInstance, and GameViewportClient which are created at game start and never destroyed until game shutdown), by the following order:

  1. GameInstance: In Standalone is created once on game start inside UGameEngine::Init(), and in PIE is created for each PIE instance inside UEditorEngine::CreateInnerProcessPIEGameInstance(). Same GameInstance is set to be used in the new loaded level inside UEngine::LoadMap().
  2. GameMode: Created by GameInstance when server loads the map inside UGameInstance::CreateGameModeForURL(), called by UWorld::SetGameMode(), called by UEngine::LoadMap().
  3. GameSession: Created by GameMode inside AGameModeBase::InitGame().
  4. GameState: Created by GameMode inside AGameModeBase::PreInitializeComponents().
  5. GameNetworkManager: Created by GameMode inside AGameModeBase::PreInitializeComponents().
  6. PlayerController: Created by GameMode, either after a successful login inside AGameModeBase::SpawnPlayerController(), called by AGameModeBase::Login(), called by UWorld::SpawnPlayActor() in case of hard travel. However, in case of seamless travel: If the new GameMode’s class is a subclass of AGameMode and its PlayerController class is the same as the previous, then the same old PlayerController is kept, and none is created. Otherwise, they are different classes, and it’s created inside AGameMode(Base)::HandleSeamlessTravelPlayer().
  7. SpectatorPawn: Created by PlayerController inside APlayerController::SpawnSpectatorPawn(), called by APlayerController::BeginSpectatingState(), which is either called by APlayerController::ReceivedPlayer(), called by APlayerController::SetPlayer(), called by UWorld::SpawnPlayActor() in case of hard travel, or called by APlayerController::ChangeState(), called by AGameMode(Base)::InitSeamlessTravelPlayer(), called by AGameMode(Base)::HandleSeamlessTravelPlayer() in case of seamless travel.
    It’s destroyed (as soon as a Pawn is possessed) inside APlayerController::DestroySpectatorPawn(), called by APlayerController::ChangeState(), called by APlayerController::OnPossess(), called by AController::Possess(), called by AGameModeBase::FinishRestartPlayer().
  8. PlayerState: Created by PlayerController inside AController::InitPlayerState(), called byAPlayerController::PostInitializeComponents() in case of player, and created by AIController inside AController::InitPlayerState(), called by AAIController::PostInitializeComponents().
  9. PlayerCameraManager: Created by PlayerController inside APlayerController::SpawnPlayerCameraManager() called by APlayerController::PostInitializeComponents(), either in case of hard travel, or in case of seamless travel and the new GameMode’s PlayerController class is different than the previous. Otherwise, in case of seamless travel and they are the same class: If the player is either a client or listen-server, the same old PlayerCameraManager is kept, and none is created. However, for a dedicated-server, it’s created inside APlayerController::PostSeamlessTravel().
  10. CheatManager: Created by PlayerController inside APlayerController::AddCheats(), called by either APlayerController::PostInitializeComponents() in case of PIE/single-player, or EnableCheats() in any other case but a shipping build.
  11. HUD: Created by PlayerController inside APlayerController::ClientSetHUD(), called by AGameModeBase::InitializeHUDForPlayer(), called by AGameModeBase::GenericPlayerInitialization() which is called for both seamless and hard travels. In case of seamless travel, if the new GameMode’s class is a subclass of AGameMode and its PlayerController class is the same as the previous, the old HUD will be valid inside APlayerController::ClientSetHUD(), and will be destroyed before the new one is created (we will see below how we can handle copying the data from the old instance to the new one or even make it truly persistent).
  12. Pawn: Created by GameMode inside AGameModeBase::RestartPlayer(), called by AGameMode(Base)::HandleStartingNewPlayer(), called by either AGameMode(Base)::PostLogin() in case of hard travel, or AGameMode(Base)::HandleSeamlessTravelPlayer() in case of seamless travel.
  13. AIController: Created by AI Pawn inside APawn::SpawnDefaultController(), called by APawn::PostInitializeComponents().

Note: I’m not providing you with the full creation call stack, neither the whole Objects list, as that would be insane, but I’m shedding light on the interesting ones. For more detailed info, I suggest you watch Alex Forsythe’s video.

Tip: Use AGameMode(Base)::HandleStartingNewPlayer() as an entry function, as it’s called regardless of whether the travel was seamless or not.

GameFramework Objects Creation Space

GameMode, GameSession, GameNetworkManager and AIController exist only on server.

GameInstance and GameState exist on server and client, though the former isn’t replicated and the latter is.

PlayerState and Pawn are replicated, existing for every (autonomous and simulated) proxy on server and client.

PlayerController is replicated, existing on server for every proxy, and only on owning client (autonomous proxy).

PlayerCameraManager exists for every proxy on server, and only on owning client, though isn’t replicated.

SpectatorPawn and HUD exist only on owning client.

CheatManager exists only on server, but can exist on owning client if the latter calls APlayerController::EnableCheats().

Note: Usually these Actor classes are different between two different maps (source and destination maps); therefore, if we want to persist data we need to have both child Actor classes inherit from a shared parent Actor class with the shared data inside it.

Persisting Data across Seamless Travel

The fact that traveling gets most of our Objects destroyed, makes the process of persisting runtime data a hard mission. Nevertheless, in this context, seamless travel can make our life much easier.

Seamless Travel Flow

  1. Mark Actors that will persist to the transition level (inner non-Actor Objects will automatically persist too).
  2. Travel to the transition level.
  3. Mark Actors that will persist to the final level (inner non-Actor Objects will automatically persist too).
  4. Travel to the final level.

Persistent Objects on Server to Transition Map only

When we seamless travel these Objects will persist by default to the transition map:

  • GameMode
  • GameSession
  • GameState
  • Any non-Actor Objects that are inside any of the previously mentioned Actors (i.e. Object.Outer == Actor)

See the function below for reference:

GameModeBase.cpp

void AGameModeBase::GetSeamlessTravelActorList(bool bToTransition, TArray<AActor*>& ActorList)
{
    // Get allocations for the elements we're going to add handled in one go
    const int32 ActorsToAddCount = GameState->PlayerArray.Num() + (bToTransition ? 3 : 0);
    ActorList.Reserve(ActorsToAddCount);

    ...

    if (bToTransition) // true if we are going from old level to transition map, false if we are going from transition map to new level
    {
        // Keep ourselves until we transition to the transition map
        ActorList.Add(this);
        // Keep general game state until we transition to the transition map
        ActorList.Add(GameState);
        // Keep the game session state until we transition to the transition map
        ActorList.Add(GameSession);

        // If adding in this section best to increase the literal above for the ActorsToAddCount
    }
}

Note: I fixed the comments inside the if block, as they originally stated that GameMode, GameState, and GameSession persist to the destination map, which is totally misleading. Also, note that the docs are misleading, as in fact they also state that the GameMode persists to the destination map, which is incorrect.

Should they be Kept to Destination Map?

While you can override the function above to keep those Actors to the destination map, I suggest you not to do so, for several reasons:

  1. There should be no reason to persist runtime data in GameMode, as this class defines the game rules which are set at compile time.
  2. GameMode class usually varies between two different levels, so keeping the same Actor is a bad idea.
  3. Keeping one of these classes and not the others is like going down the rabbit hole. For example, keeping GameMode and not GameState will cause the server to shutdown, as these two classes are coupled, as you can see here:

    World.cpp

     UWorld* FSeamlessTravelHandler::Tick()
     {
         ...
    
         if (KeptGameMode)
         {
             LoadedWorld->CopyGameState(KeptGameMode, KeptGameState);
             bCreateNewGameMode = false;
         }
    
         ...
     }
    
     void UWorld::CopyGameState(AGameModeBase*FromGameMode, AGameStateBase* FromGameState)
     {
         AuthorityGameMode = FromGameMode;
         SetGameState(FromGameState);
     }
    
  4. Even if we kept both GameMode and GameState, our game will freeze, and there is no way to unfreeze it, unless we reconnect our clients.

Tip: Don’t persist runtime data with these Actor classes: GameMode, GameState, and GameSession.

Persistent Objects on Server to Destination Map

By default, the following Objects will persist only on server to the destination map, though, sometimes they will be destroyed and recreated. Therefore, we’ll have to copy data (more on this below) over to the new created ones. Here’s the list:

  • All PlayerStates
  • All Controllers that have a valid PlayerState (including AIControllers that use PlayerStates)
  • All PlayerControllers
  • Listen-server’s HUD
  • Listen-server’s PlayerCameraManager
  • All Listen-server’s UserWidgets
  • Any Actors further added via APlayerController::GetSeamlessTravelActorList() called on listen-server’s PlayerController
  • Any Actors further added via AGameModeBase::GetSeamlessTravelActorList()
  • Any non-Actor Objects that are inside an Actor that is in the list (i.e. Object.Outer == Actor in the list ActorList)
  • Any Actors that have: (Role < ROLE_Authority) && (NetDormancy < DORM_DormantAll) && (!IsNetStartupActor())

Note: Only dynamic Actors (assigned a dynamic NetGUID, and this includes but isn’t limited to all Actors spawned during gameplay) in the PersistentLevel may persist.

See the functions below for reference:

World.cpp

UWorld* FSeamlessTravelHandler::Tick()
{
    ...

    // mark actors we want to keep
    FUObjectAnnotationSparseBool KeepAnnotation;
    TArray<AActor*> KeepActors;

    if (AGameModeBase* AuthGameMode = CurrentWorld->GetAuthGameMode())
    {
        AuthGameMode->GetSeamlessTravelActorList(!bSwitchedToDefaultMap, KeepActors);
    }

    const bool bIsClient = (CurrentWorld->GetNetMode() == NM_Client);

    // always keep Controllers that belong to players
    if (bIsClient)
    {
        ...
    }
    else
    {
        for( FConstControllerIterator Iterator = CurrentWorld->GetControllerIterator(); Iterator; ++Iterator )
        {
            if (AController* Player = Iterator->Get())
            {
                if (Player->PlayerState || Cast<APlayerController>(Player) != nullptr)
                {
                    KeepAnnotation.Set(Player);
                }
            }
        }
    }

    // ask players what else we should keep
    for (FLocalPlayerIterator It(GEngine, CurrentWorld); It; ++It)
    {
        if (It->PlayerController != nullptr)
        {
            It->PlayerController->GetSeamlessTravelActorList(!bSwitchedToDefaultMap, KeepActors);
        }
    }
    // mark all valid actors specified
    for (AActor* KeepActor : KeepActors)
    {
        if (KeepActor != nullptr)
        {
            KeepAnnotation.Set(KeepActor);
        }
    }

    ...
}

GameModeBase.cpp

void AGameModeBase::GetSeamlessTravelActorList(bool bToTransition, TArray<AActor*>& ActorList)
{
    // Get allocations for the elements we're going to add handled in one go
    const int32 ActorsToAddCount = GameState->PlayerArray.Num() + (bToTransition ? 3 : 0);
    ActorList.Reserve(ActorsToAddCount);

    // Always keep PlayerStates, so that after we restart we can keep players on the same team, etc
    ActorList.Append(GameState->PlayerArray);

    ...
}

PlayerController.cpp

void APlayerController::GetSeamlessTravelActorList(bool bToEntry, TArray<AActor*>& ActorList)
{
    if (MyHUD != NULL)
    {
        ActorList.Add(MyHUD);
    }

    // Should player camera persist or just be recreated?  (clients have to recreate on host)
    ActorList.Add(PlayerCameraManager);
}

Persistent Objects on Client to Destination Map

By default, the following Objects will persist only on client to the destination map, though, sometimes they will be destroyed and recreated. Therefore, we’ll have to copy data (more on this below) over to the new created ones. Here’s the list:

  • local PlayerController
  • HUD
  • local PlayerCameraManager
  • All UserWidgets
  • Any Actors further added via APlayerController::GetSeamlessTravelActorList() called on local PlayerController
  • Any non-Actor Objects that are inside an Actor that is in the list (i.e. Object.Outer == Actor in the list)
  • Any Actors that have: (Role < ROLE_Authority) && (NetDormancy < DORM_DormantAll) && (!IsNetStartupActor())

Note: Only dynamic Actors in the PersistentLevel may persist.

See the function below for reference:

World.cpp

UWorld* FSeamlessTravelHandler::Tick()
{
    ...

    // mark actors we want to keep
    FUObjectAnnotationSparseBool KeepAnnotation;
    TArray<AActor*> KeepActors;

    const bool bIsClient = (CurrentWorld->GetNetMode() == NM_Client);

    // always keep Controllers that belong to players
    if (bIsClient)
    {
        for (FLocalPlayerIterator It(GEngine, CurrentWorld); It; ++It)
        {
            if (It->PlayerController != nullptr)
            {
                KeepAnnotation.Set(It->PlayerController);
            }
        }
    }
    else
    {
        ...
    }

    // ask players what else we should keep
    for (FLocalPlayerIterator It(GEngine, CurrentWorld); It; ++It)
    {
        if (It->PlayerController != nullptr)
        {
            It->PlayerController->GetSeamlessTravelActorList(!bSwitchedToDefaultMap, KeepActors);
        }
    }
    // mark all valid actors specified
    for (AActor* KeepActor : KeepActors)
    {
        if (KeepActor != nullptr)
        {
            KeepAnnotation.Set(KeepActor);
        }
    }

    ...
}

PlayerController.cpp

void APlayerController::GetSeamlessTravelActorList(bool bToEntry, TArray<AActor*>& ActorList)
{
    if (MyHUD != NULL)
    {
        ActorList.Add(MyHUD);
    }

    // Should player camera persist or just be recreated?  (clients have to recreate on host)
    ActorList.Add(PlayerCameraManager);
}

Persistent non-Actor Objects across Seamless Travel

Caution: This section is still a WIP, and so you shouldn’t be taking my words for granted!

If you look closely at the docs of a function like GetSeamlessTravelActorList() and read between the lines, you will quickly realize that apart from the persistent list of Actors, there are also non-Actor Objects that could potentially persist, due to this: (i.e. Object.Outer == Actor in the list). In another context, I noticed that UserWidgets persist only seamless travels and they are usually outer’d to GameInstance (in case one doesn’t exist, they are outer’d to World), which is persistent regardless of the travel type. On the one hand, if one happens to change their Outer (via UObject::Rename()) to Pawn for example, which isn’t persistent by default, then they won’t persist anymore, while on the other hand, changing their Outer to a persistent PlayerController will keep them persistent.

This led me to the following theory:

Upon seamless travel, any non-Actor Object that is outer’d to a persistent Object will also be persistent.

One assumption to why this might be the case is that Outer holds a strong/hard reference to its inners, preventing them from being automatically GC’d (garbage collected). Though, that’s not the case at all, and therefore the Outer of an inner Object has nothing to do with the lifetime of that inner from a GC perspective. However, inner holds a strong reference to its Outer, so as long as the inner is alive, its Outer will also be kept alive. An exception to this rule is UPackage. It is the root “thing” that actually gets saved and loaded by the engine, and therefore has no Outer. The Object outer’d to that Package, will be saved along with it, making the Outer relationship very important in this case.

The other assumption is that the GC ignores unreachable non-Actor Objects that are outer’d to a persistent Object upon seamless travels. I have yet to prove this is the case though.

Persistent UserWidgets

We’ve already come to mention that by default UserWidgets are usually outer’d to GameInstance, and that’s why they are persistent on seamless travel. With that said, it’s quite interesting to see how they are handled in hard travels.

During a hard travel, UEngine::LoadMap() gets called, and at some point the delegate FWorldDelegates::LevelRemovedFromWorld gets broadcasted. When a UserWidget has UUserWidget::AddToViewport() called on it, it internally calls UUserWidget::AddToScreen(), which binds the previous delegate to the function UUserWidget::OnLevelRemovedFromWorld(), which calls UUserWidget::RemoveFromParent() as can be seen down below:

UserWidget.h

/**
 * Called when a top level widget is in the viewport and the world is potentially coming to and end. When this occurs,
 * it's not safe to keep widgets on the screen. We automatically remove them when this happens and mark them for pending kill.
 */
virtual void OnLevelRemovedFromWorld(ULevel* InLevel, UWorld* InWorld);

UserWidget.cpp

void UUserWidget::OnLevelRemovedFromWorld(ULevel* InLevel, UWorld* InWorld)
{
    // If the InLevel is null, it's a signal that the entire world is about to disappear, so
    // go ahead and remove this widget from the viewport, it could be holding onto too many
    // dangerous actor references that won't carry over into the next world.
    if ( InLevel == nullptr && InWorld == GetWorld() )
    {
        RemoveFromParent();
    }
}

When a UserWidget gets destroyed, its underlying Slate Widget won’t be automatically removed from the viewport, hence the above function OnLevelRemovedFromWorld() ensures that for the stated reason.

To sum up, during hard travel UserWidgets get destroyed and so their underlying Slate Widget, while during seamless travel they will persist just fine.

Should UserWidgets Persist Seamless Travel?

While reading through the previous section, you might have asked yourself if it’s a good practice for UserWidgets to be persisting a seamless travel, and the short answer to that is no. Reason being is that UI (User Interface) is for displaying state and accepting input. Causing it to carry that state past level loads turns it into the state holder and not the displayer. Therefore, UserWidgets should be mostly stateless (aside from visual effects required by the UserWidget itself for example), i.e. just mirroring data held elsewhere, and in return they should almost never need to persist.

There are multiple options to how you can prevent them from persisting, but here two:

  1. Mimic what the engine does for hard travel: Override UUserWidget::AddToScreen() (in a custom UserWidgetBase class, that every UserWidget inherits from) and listen for the delegate FWorldDelegates::OnSeamlessTravelStart, which calls a custom UserWidgetBase::OnSeamlessTravelStart() that calls an overridden UUserWidgetBase::RemoveFromParent().
  2. Manually remove them when their manager HUD gets destroyed: Override AActor::Destroyed() in your HUD class (great UserWidgets manager) and iterate through your UserWidgets and explicitly call UUserWidget::RemoveFromParent() on them.

Meaning of Persistent Objects

We have already seen that almost all Objects, including some of those mentioned to be persistent, get destroyed and recreated by the time we seamless travel. If that’s the case, then how persistent Objects mentioned in the previous sections do really persist?

When a seamless travel occurs, there is a short period of time, where old and new instances of the specific Object class are alive. In that period of time, only the data you choose to copy from old instance over to the new one, is what really gets kept. Therefore, the old Objects don’t really persist, but the data we choose to keep. However, this is not always the case. For example, UserWidgets (and any non-Actor Object that has a persistent Outer) do truly persist without needing to copy data over. Same goes for classes like PlayerController and PlayerCameraManager, but only if the new GameMode’s class is a subclass of AGameMode and its PlayerController class is the same as the previous. We will explain the reasoning and how that’s done under the hood. The more thorough answer, and how the data keeping is done, is found in the next sections.

Persisting Data across Disconnects

While the modern internet allows gamers to connect with each other all over the world, the internet is sometimes not as stable as we would like it to be. Disconnections occur on the daily basis in multiplayer games, and if not handled properly, players will suffer. Disconnecting and reconnecting is by definition a hard travel, meaning we lack the benefits of seamless travel persistent data we discussed above. Luckily, Unreal has an already built in functionality that handles the data keeping of disconnecting and then reconnecting players, out of the box. To get how this functionality works all together, we should understand first how the engine handles a disconnecting player.

Storing Data on Disconnection

Here’s the call stack (by order) for a player who disconnects, up to the point where his data is saved:

1. APlayerController::Destroyed()
2. AController::Destroyed()
3. AGameMode::LogOut()
4. AGameMode::AddInactivePlayer()
5. APlayerState::Duplicate()
6. APlayerState::DispatchCopyProperties()
7. APlayerState::CopyProperties(), APlayerState::ReceiveCopyProperties() // Native and Blueprint

Note: Your GameMode custom class must be inheriting from AGameMode instead of AGameModeBase as the latter has no support for such functionality.

Now in words, this what happens when a player disconnects from the game server :

  1. His Pawn is unpossessed from the PlayerController and destroyed inside APlayerController::PawnLeavingGame(). SpectatorPawn, HUD and PlayerCameraManager are all destroyed too.
  2. A new “copy” PlayerState is spawned, and data specified inside the native and Blueprint CopyProperties() functions, is copied over from the old original PlayerState to the newly created one. Override either one, and decide what data you want to copy accordingly. Here’s the data copied by default:

    PlayerState.cpp

     void APlayerState::CopyProperties(APlayerState* PlayerState)
     {
         PlayerState->SetScore(GetScore());
         PlayerState->SetCompressedPing(GetCompressedPing());
         PlayerState->ExactPing = ExactPing;
         PlayerState->SetPlayerId(GetPlayerId());
         PlayerState->SetUniqueId(GetUniqueId());
         PlayerState->SetPlayerNameInternal(GetPlayerName());
         PlayerState->SetStartTime(GetStartTime());
         PlayerState->SavedNetworkAddress = SavedNetworkAddress;
     }
    
  3. The new copy PlayerState is then deactivated (not replicating anymore), and its lifespan is set to be AGameMode.InactivePlayerStateLifeSpan which is 300 seconds by default. If the player doesn’t reconnect by that time, their inactive stored copy PlayerState will be destroyed, and he won’t be able to reconnect again (as his UniqueId or SavedNetworkAddress is lost). Setting InactivePlayerStateLifeSpan to 0 will clear the timer, and the stored copy PlayerState won’t be destroyed. The inactive PlayerState is added to AGameMode.InactivePlayerArray, which is an array of PlayerStates belonging to players who have disconnected from the server, so they are saved in case they reconnect. Note that AGameMode.MaxInactivePlayers determines the maximum number of disconnected players before the older ones are kicked out.
  4. The original PlayerState is then destroyed inside APlayerState::OnDeactivated(), called by APlayerController::CleanupPlayerState(), which in turn removes it from AGameStateBase.PlayerArray (array of all active PlayerStates) inside APlayerState::Destroyed(). The owning PlayerController is destroyed afterwards.

Note: By default, one of the conditions of which the PlayerState is duplicated and copied, is if APlayerState.bOnlySpectator == false for the disconnecting player, which means he didn’t join the server as a spectator.

With all that being said, there are two pitfalls, which we will cover in the next section.

The Pitfalls

(1) Thanks to my friend Zlo for his insight on the first pitfall. Quoting him:

While it seems APlayerState::CopyProperties() is the function to stash any data on disconnection, in fact it’s not, as you are likely going to need to pull some pawn-related data, which in order to restore it properly on reconnection, that function fails to do so.

For example, suppose you want disconnected pawns to reappear on the spot where they disconnected. That requires the PlayerState to know the Pawn’s location by the time he disconnects, which it often doesn’t. Same goes for your inventory or skill systems, which even if you had the foresight to put them inside the PlayerState class, CopyProperties() remains to be an issue.

For CopyProperties() to work, PlayerState has to have all the data you need already, and for disconnection purposes, it usually doesn’t. Therefore, you need a function that gets called at a later point in time. APlayerState::OnDeactivated() is the one.

PlayerState.h

/** Called on the server when the owning player has disconnected, by default this method destroys this player state */
virtual void OnDeactivated();

(2) While it is common for an Actor to have its own AbilitySystemComponent, there are cases in which you might want an Actor, such as a player’s Pawn, to use an AbilitySystemComponent owned by another Actor, like a PlayerState or PlayerController. Reasons for this may include things like a player’s score, or long-lasting ability cooldown timers that do not reset when the player’s Pawn is destroyed and respawned, or when the player possesses a new Pawn.

For the said reason, and for disconnection purposes, we will attach the AbilitySystemComponent to PlayerState. However, as soon as we try to duplicate our PlayerState, we will find out that the AbilitySystemComponent’s AttributeSets are being set to nullptr instead of the proper value, and it turns out to be an engine bug. While there are workarounds to this, in my opinion the proper and easy fix is to make our original PlayerState stick around, without creating any duplicates. This also saves us some time spent on copying PlayerState related properties to the duplicate, especially when there are too many of them, making us less error-prone.

The Better, More Reliable Way to Stash Data on Disconnection

While the properties you want to stash on disconnection can be scattered all over the PlayerState class, the much more decent, optimal way is to encapsulate them using a struct. That way, even when there’s a big pile of data we want to save, we can still refer to them quite fast.

PlayerState.h

// Disconnected hero's non-PlayerState related data will be stored here
USTRUCT()
struct FDisconnectedHeroData
{
    GENERATED_BODY()

public:

    /** Hero's transform (location, rotation, scale) */
    UPROPERTY()
    FTransform Transform;

    /** Hero's health */
    UPROPERTY()
    int32 Health;
};

UCLASS()
class AMyPlayerState: public APlayerState
{
    GENERATED_BODY()

public:
    /** Disconnected player's stashed data, so we reapply it on reconnection */
    UPROPERTY()
    FDisconnectedHeroData DisconnectedHeroData;

    ...

};

For us to be able to pull pawn-related data inside APlayerState::OnDeactivated(), the pawn has to be valid and possessed at that time. However, the Pawn is unposseded and destroyed before APlayerState::OnDeactivated() or even APlayerState::CopyProperties() are called. Specifically, it gets destroyed in the following function:

PlayerController.cpp

void APlayerController::Destroyed()
{
    if (GetPawn() != NULL)
    {
        // Handle players leaving the game
        if (Player == NULL && GetLocalRole() == ROLE_Authority)
        {
            PawnLeavingGame(); // Destroys our pawn
        }
        else
        {
            UnPossess(); // Unpossess our pawn, nulling out APlayerState.PawnPrivate
        }
    }

    ...

    Super::Destroyed();
}

For that reason, we have to override said function to neither destroy the Pawn nor unpossess it, and instead delegate the functionality to APlayerState::OnDeactivated(), which gets called at a later stage:

MyPlayerController.cpp

void AMyPlayerController::Destroyed()
{
    if (GetSpectatorPawn() != NULL)
    {
        DestroySpectatorPawn();
    }

    if ( MyHUD != NULL )
    {
        MyHUD->Destroy();
        MyHUD = NULL;
    }

    if (PlayerCameraManager != NULL)
    {
        PlayerCameraManager->Destroy();
        PlayerCameraManager = NULL;
    }

    // Tells the game info to forcibly remove this player's CanUnpause delegates from its list of Pausers.
    // Prevents the game from being stuck in a paused state when a PC that paused the game is destroyed before the game is unpaused.
    AGameModeBase* const GameMode = GetWorld()->GetAuthGameMode();
    if (GameMode)
    {
        GameMode->ForceClearUnpauseDelegates(this);
    }

    PlayerInput = NULL;
    CheatManager = NULL;

    Super::Super::Destroyed(); // or AController::Destroyed();
}

Moving on, we override AGameMode::AddInactivePlayer() so it doesn’t duplicate our original PlayerState (i.e. APlayerState::Duplicate() isn’t called, and APlayerState::CopyProperties() accordingly), but instead we make it stick around:

MyGameMode.h

/** Add PlayerState to the inactive list, remove from the active list */
virtual void AddInactivePlayer(APlayerState* PlayerState, APlayerController* PC) override;

MyGameMode.cpp

void AMyGameMode::AddInactivePlayer(APlayerState* PlayerState, APlayerController* PC)
{
    check(PlayerState)
    UWorld* LocalWorld = GetWorld();
    // don't store if it's an old PlayerState from the previous level or if it's a spectator... or if we are shutting down
    if (!PlayerState->IsFromPreviousLevel() && !MustSpectate(PC) && !LocalWorld->bIsTearingDown)
    {
        // We remove the PlayerState from the active PlayerArray as it's going to stick around (see APlayerState::Destroyed)
        GameState->RemovePlayerState(PlayerState);

        // make PlayerState inactive
        PlayerState->SetReplicates(false);

        // delete after some time
        PlayerState->SetLifeSpan(InactivePlayerStateLifeSpan);

        // On console, we have to check the unique net id as network address isn't valid
        const bool bIsConsole = !PLATFORM_DESKTOP;
        // Assume valid unique ids means comparison should be via this method
        const bool bHasValidUniqueId = PlayerState->GetUniqueId().IsValid();
        // Don't accidentally compare empty network addresses (already issue with two clients on same machine during development)
        const bool bHasValidNetworkAddress = !PlayerState->SavedNetworkAddress.IsEmpty();
        const bool bUseUniqueIdCheck = bIsConsole || bHasValidUniqueId;

        // make sure no duplicates
        for (int32 Idx = 0; Idx < InactivePlayerArray.Num(); ++Idx)
        {
            APlayerState* const CurrentPlayerState = InactivePlayerArray[Idx];
            if (!IsValid(CurrentPlayerState))
            {
                // already destroyed, just remove it
                InactivePlayerArray.RemoveAt(Idx, 1);
                Idx--;
            }
            else if ((!bUseUniqueIdCheck && bHasValidNetworkAddress && (CurrentPlayerState->SavedNetworkAddress ==
                    PlayerState->SavedNetworkAddress))
                || (bUseUniqueIdCheck && (CurrentPlayerState->GetUniqueId() == PlayerState->GetUniqueId())))
            {
                // destroy the PlayerState, then remove it from the tracking
                CurrentPlayerState->Destroy();
                InactivePlayerArray.RemoveAt(Idx, 1);
                Idx--;
            }
        }
        InactivePlayerArray.Add(PlayerState);

        // make sure we don't go over the maximum number of inactive players allowed
        if (InactivePlayerArray.Num() > MaxInactivePlayers)
        {
            int32 const NumToRemove = InactivePlayerArray.Num() - MaxInactivePlayers;

            // destroy the extra inactive players
            for (int Idx = 0; Idx < NumToRemove; ++Idx)
            {
                APlayerState* const PS = InactivePlayerArray[Idx];
                if (PS != nullptr)
                {
                    PS->Destroy();
                }
            }

            // and then remove them from the tracking array
            InactivePlayerArray.RemoveAt(0, NumToRemove);
        }
    }
}

Next, we override APlayerState::OnDeactivated(), which by default destroys our original PlayerState, so it doesn’t do so, and in which we will populate our FDisconnectedHeroData struct exclusively:

MyPlayerState.cpp

void AMyPlayerState::OnDeactivated()
{
    if (const AMyPawn* MyPawn = GetPawn<AMyPawn>())
    {
        DisconnectedHeroData.Transform = MyPawn->GetTransform();
        DisconnectedHeroData.Health = MyPawn->GetHealth();
        // Further pawn-related data can be pulled and stashed in here
    }

    if (APlayerController* PC = GetPlayerController())
    {
        // Handle players leaving the game
        if (!PC->Player)
        {
            PC->PawnLeavingGame();
        }
        else
        {
            PC->UnPossess();
        }
    }
}

Last but not least, when a player logs back in, we will have to tell if he’s a reconnecting player, so we respawn him at his old Transform:

MyPlayerState.h

private:
/** Means this PlayerState belongs to a reconnecting player */
uint8 bIsReconnecting:1;

public:
/** Gets the literal value of bIsReconnecting. */
bool IsReconnecting() const
{
    return bIsReconnecting;
}

/** Called on the server when the owning player has reconnected and this player state is added to the active players array */
virtual void OnReactivated() override;

MyPlayerState.cpp

void AMyPlayerState::OnReactivated()
{
    bIsReconnecting = true;
}

By default, a starting new player is always spawned at a PlayerStart. Instead, we make him spawn at his old Transform, in case he was reconnecting:

MyGameMode.h

/** Tries to spawn the player's pawn, either at his old location in case he's reconnecting, or at the location returned by FindPlayerStart */
virtual void RestartPlayer(AController* NewPlayer) override;

MyGameMode.cpp

void AMyGameMode::RestartPlayer(AController* NewPlayer)
{
    if (NewPlayer == nullptr || NewPlayer->IsPendingKillPending())
    {
        return;
    }

    const AMyPlayerState* PS = NewPlayer->GetPlayerState<AMyPlayerState>();
    if(PS && PS->IsReconnecting())
    {
        RestartPlayerAtTransform(NewPlayer, PS->DisconnectedHeroData.Transform);
    }
    else
    {
        AActor* StartSpot = FindPlayerStart(NewPlayer);

        // If a start spot wasn't found,
        if (StartSpot == nullptr)
        {
            // Check for a previously assigned spot
            if (NewPlayer->StartSpot != nullptr)
            {
                StartSpot = NewPlayer->StartSpot.Get();
                UE_LOG(LogGameMode, Warning, TEXT("RestartPlayer: Player start not found, using last start spot"));
            }
        }

        RestartPlayerAtPlayerStart(NewPlayer, StartSpot);
    }
}

Restoring Data on Reconnection

Here’s the call stack (by order) for a player who reconnects, up to the point where his data is overridden:

1. UWorld::SpawnPlayActor()
2. AGameMode::Login(), AGameMode::PostLogin()
3. AGameMode::FindInactivePlayer()
4. AGameMode::OverridePlayerState()
5. APlayerState::DispatchOverrideWith()
6. APlayerState::OverrideWith(), APlayerState::ReceiveOverrideWith() // Native and Blueprint

Now in words, this is part of what happens when a player logs back into the game server :

  1. A new PlayerController and Pawn (which becomes possessed by the former) are recreated for him by GameMode.
  2. A new PlayerState, PlayerCameraManager and HUD are recreated for him by PlayerController.
  3. The original inactive PlayerState CurrentPlayerState is retrieved and set to be our used PlayerState. It is then owned back by our newly created PlayerController, reactivated, its lifespan timer is cleared and it’s set not to be destroyed.
  4. Now looking at the local variable OldPlayerState, as its name suggests, one may guess it’s assigned the old/original inactive PlayerState. What makes such an assumption even more legitimate is the fact that it is being passed to AGameMode::OverridePlayerState(), which in turn passes it at some point to APlayerState::ReceiveOverrideWith() which its documentation suggests the following:

PlayerState.h

/*
 * Can be implemented in Blueprint Child to move more properties from old to new PlayerState when reconnecting
 *
 * @param OldPlayerState     Old PlayerState, which we use to fill the new one with
 */
UFUNCTION(BlueprintImplementableEvent, Category = PlayerState, meta = (DisplayName = "OverrideWith"))
void ReceiveOverrideWith(APlayerState* OldPlayerState);

While in fact the local variable OldPlayerState is assigned the freshly spawned, “empty” PlayerState. Therefore, AGameMode::OverridePlayerState() is called on our original just activated PlayerState, passing in the “pointlessly” spawned, almost empty PlayerState. Here’s what gets overridden in the process:

PlayerState.cpp

void APlayerState::OverrideWith(APlayerState* PlayerState)
{
    SetIsSpectator(PlayerState->IsSpectator());
    SetIsOnlyASpectator(PlayerState->IsOnlyASpectator());
    SetUniqueId(PlayerState->GetUniqueId());
    SetPlayerNameInternal(PlayerState->GetPlayerName());
}

As you can see, what gets overridden is so marginal, that most probably is important for the reconnection process.
In theory, you could be using this function to override other types of data that could be outdated by the time that the player has reconnected. For example, when teams switch sides. If such case isn’t handled properly, the reconnected player might find himself in the wrong team!

Lastly, the almost empty OldPlayerState then calls SetIsInactive(true), which in turn calls APlayerState::OnRep_bIsInactive(), preventing it from registering with AGameStateBase.PlayerArray. OldPlayerState is then destroyed, and the original, just reactivated PlayerState calls APlayerState::OnReactivated() which does nothing by default, though we’ve made a good use with it above.

Note: Unfortunately, the current way to match players with their previous PlayerState is by comparing their IP addresses. If multiple players are playing with the same remote IP address, the wrong player could take over another disconnected player on their network. Oddly, this doesn’t affect console builds, as they check the player’s PlayerState.UniqueId (referred as UniqueNetId in OnlineSubsystems), which is a better unique identifier. The default OSS is OnlineSubsystemNull which has no valid UniqueNetId, that’s why a backend with unique users is needed, i.e., APlayerState::GetUniqueId() is only relevant/consistent if you have an OSS loaded such as Steam or EOS.

How to Disconnect/Reconnect?

In theory, there could be multiple ways of implementing such functionalities. Thankfully, Unreal already has those functionalities implemented as console commands. Some of the commands we type in the console command-line is parsed at some point inside the following function:

UnrealEngine.cpp

bool UEngine::Exec( UWorld*InWorld, const TCHAR* Cmd, FOutputDevice& Ar )
{
    ...

    else if( FParse::Command( &Cmd, TEXT("DISCONNECT")) )
    {
        return HandleDisconnectCommand( Cmd, Ar, InWorld );
    }
    else if( FParse::Command( &Cmd, TEXT("RECONNECT")) )
    {
        return HandleReconnectCommand( Cmd, Ar, InWorld );
    }

    ...
}
  • DISCONNECT: Disconnects the client from the current game/server. It corresponds to the native function UEngine::HandleDisconnectCommand(), which calls UEngine::HandleDisconnect(). Disconnection is in fact a travel of type Absolute or Partial (depending on the disconnection circumstances) which in either case results in hard travel.
  • RECONNECT: Reconnects the client to the current game/server. It corresponds to the native function UEngine::HandleReconnectCommand(). Disconnection is in fact a travel of type Absolute, which results in hard travel.

Note: Those commands aren’t autocompleted like the travel-related ones we’ve seen earlier, as they don’t have ManualAutoCompleteList set for them.

Here is a couple of places you can choose from, to perform the functionalities above in code:

  • UKismetSystemLibrary::ExecuteConsoleCommand() native function (the Blueprint version has the same name) and Command parameter be either DISCONNECT or RECONNECT.
  • UGameInstance::Exec() function and Cmd parameter be either DISCONNECT or RECONNECT.

Persistent Runtime Data

Luckily there are multiple options to consider, when it comes to saving our data across level changes or reconnects. Note that the Usage section in each and everyone of these options reflects my own point of view; thus, you can deviate from the script. As a rule of thumb, I tend to store server-authoritative data server-side, and client-authoritative data client-side. However, sometimes server-authoritative data is intuitively stored client-side, forcing us to validate data when retrieved and before being applied.

1. GameInstance

The GameInstance is a high-level manager Object for an instance of the running game. Spawned at game creation and not destroyed until game instance is shut down. In other words, the GameInstance persists from level-to-level, regardless of the travel type, making it a good candidate to save data. Running as a standalone game, there will be one of these. Running in PIE will generate one of these per PIE instance.

How to Specify My Custom Class?

You can already tell that it shouldn’t be in the Classes category inside your custom GameMode’s Class Defaults, since the GameInstance isn’t level specific, and so it isn’t tied to a GameMode.

To configure your project to use your desired custom GameInstance, look into Project Settings->Maps & Modes, and you should see this at the very bottom:

GameInstance Configuration

Where does this Class Exist?

This class exists on both server and client, though it isn’t replicable; thus, you can’t be replicating any data inside this class.

Saving & Retrieving Data

This is probably the easiest method, as all you have to do is simply save the data before traveling to a new level, and retrieve the saved data after you’re done traveling to that level. As for disconnection, it’s not any different. A reconnecting player, is in fact a player traveling to the map he was in. We have already seen that APlayerState::OnDeactivated() is a good point in time for saving data on disconnection; therefore, pull the data you need there, and hook it up in your GameInstance. When a client disconnects, his client-side GameInstance remains in memory on his own local machine. Upon reconnection, data can be retrieved, validated and reapplied where needed.

Usage

  • Persist world-state data between levels upon hard travels.
  • Stash player-specific data on disconnection, and retrieve it on reconnection.

Tip: Consider using the next option instead.

2. Programming Subsystems

Subsystems in Unreal are automatically instanced classes with managed lifetimes. These classes provide easy to use extension points, where programmers can get Blueprint and Python exposure right away while avoiding the complexity of modifying or overriding engine classes. Subsystems don’t support networking, hence you shouldn’t be replicating any data inside them directly. More info on them, and why they are useful can be found either in the offical docs or in benui’s blog post.

Where does this Class Exist?

As you might have already seen, there are 5 different parent classes to choose from. Though, we’ll shed light on those used in this context: GameInstance and LocalPlayer subsystems. Both have an almost similar (not identical) lifecycle to the one the GameInstance has, meaning that they persist both travels and disconnects.

GameInstance subsystem exists on both server and client, though as mentioned, it isn’t replicable, i.e., the two versions aren’t necessarily in sync.

LocalPlayer subsystem exists for each LocalPlayer. This means that it exists on server for the listen-server (host) player in case of a listen-server setup, and on client for each other client (hosted) player. In a dedicated-server setup, it won’t exist on server, but on client only for each client.

Saving & Retrieving Data

Saving and retrieving data in this case isn’t any different from the way it’s done in GameInstance, due to the fact that the mentioned subsystems have a similar lifetime to the GameInstance (they live outside UWorlds), making them an even better place to persist data upon traveling or disconnecting. The reason being that using GameInstance for persisting data can end up quickly bloating it, making it less optimal to handle other Objects like Sessions, SaveGames, etc.

Usage

  • Keep an optimal GameInstance.

GameInstance Subsystem

  • Persist world-state data between levels upon hard travels.
  • Stash player-specific data on disconnection, and retrieve it on reconnection.
Example Usage

Stats System that tracks the number of gathered resources.

LocalPlayer Subsystem

  • Persist player-specific and local-player related data (such as UI, input, etc.) between levels upon hard travels.
  • Stash player-specific and local-player related data on disconnection, and retrieve it on reconnection.
Example Usage

The Enhanced Input LocalPlayer subsystem (UEnhancedInputLocalPlayerSubsystem) which would allow you to add mapping contexts, bind input delegates, and more.

Note: Don’t confuse Programming Subsystems with Online Subsystems, as they are different entities.

3. PlayerState

A PlayerState is created for every player on a server (or in a standalone game). PlayerStates are relevant and replicated to all clients, and contain network game relevant information about the player, such as his name, score, ping, etc.

Where does this Class Exist?

This class Actor exists on server, replicated all clients, and is always relevant (bAlwaysRelevant = true). Therefore, each client has knowledge of his PlayerState and all the others, at all times.

Copying Data

Data is copied from old instance of this Actor over to a new one upon seamless traveling or disconnecting. Both procedures take place on the server.

Usage

  • Persist player-specific data between levels upon seamless travels.
  • Stash player-specific data on disconnection, and retrieve it on reconnection.

Example Usage

Let’s assume we’ve a custom PlayerState with some custom member variables:

MyPlayerState.h

// Seamless traveled hero's non-PlayerState related data will be stored here
USTRUCT()
struct FSeamlessTraveledHeroData
{
    GENERATED_BODY()

    /** Player's current selected hero to respawn as */
    UPROPERTY()
    TSubclassOf<APawn> SelectedHero;
}

UCLASS()
class AMyPlayerState: public APlayerState
{
    GENERATED_BODY()

public:
    /** Player's current kill count */
    UPROPERTY(Transient, Replicated)
    int32 Kills;

    /** Player's current assist count */
    UPROPERTY(Transient, Replicated)
    int32 Assists;

    /** Player's current death count */
    UPROPERTY(Transient, Replicated)
    int32 Deaths;

    /** Seamless traveled player's stored data, so we reapply it when player has finished loading */
    UPROPERTY()
    FSeamlessTraveledHeroData SeamlessTraveledHeroData;

    ...

};

By default, none of these properties are preserved when the player seamless travels (or disconnects). To fix this, we need to override the following function:

PlayerState.h

/** Copy properties which need to be saved in inactive PlayerState */
virtual void CopyProperties(APlayerState* PlayerState);

Note that this is the same function we covered earlier that preserves some data upon disconnection. As we’ve seen earlier, only a small portion of the built in properties is preserved, and none of our custom properties is unfortunately, so we have to do it ourselves.

MyPlayerState.cpp

void AMyPlayerState::CopyProperties(APlayerState* PlayerState)
{
    Super::CopyProperties(PlayerState); // This is called so we preserve data chosen to be preserved by default

    if (AMyPlayerState* NewPlayerState = Cast<AMyPlayerState>(PlayerState))
    {
        NewPlayerState->SeamlessTraveledHeroData.SelectedHero = SelectedHero;
        NewPlayerState->Kills = Kills;
        NewPlayerState->Assists = Assists;
        NewPlayerState->Deaths = Deaths;
    }
}

Note: While the old and new levels can have the same PlayerState class, the more elegant approach is to have different class for each level with a common parent PlayerState class (which they inherit from) holding all the data that gets transferred between levels.

Again, considering this function is used to persist data across travels and disconnects, what if there is data you don’t want to persist across travels, but you do want them to persist across disconnects?

That’s why APlayerState::Reset(), called by APlayerController::SeamlessTravelFrom(), is there. Properties that should be reset and not preserved across travels, should go there. This is the default implementation of that function:

PlayerState.cpp

void APlayerState::Reset()
{
    Super::Reset();
    SetScore(0);
    ForceNetUpdate();
}

As can be seen, Score property is reset, so it doesn’t persist travels, but it does persist disconnects.

Let’s override it so it looks like this:

PlayerState.cpp

void AMyPlayerState::Reset()
{
    Super::Reset(); // This is called so we reset data chosen to be reset by default, e.g., Score

    Kills = 0;
    Assists = 0;
    Deaths = 0;
}

Now, when seamless traveling: Kills, Assists, and Deaths will all be reset, but SelectedHero is kept. However, the former three properties won’t be reset when disconnecting.

Note: APlayerState::CopyProperties() gets called upon seamless and hard travels. Upon traveling, the travel has to be seamless for it to be called. Upon disconnection, the travel is hard by definition, and we’ve seen earlier that it gets called (unless we’ve made it not).

4. PlayerController

A PlayerController is the interface between the Pawn and the human player controlling it. The PlayerController essentially represents the human player’s will.

Where does this Class Exist?

This class Actor exists on server, replicated to owning client, and only to relevant to him (bOnlyRelevantToOwner = true). Therefore, each client has knowledge only of his own PlayerController.

Copying Data

Data is copied from old instance of this Actor over to a new one upon seamless traveling, and only if either the new GameMode’s PlayerController class is different than the previous or the new GameMode’s class is a subclass of AGameModeBase (not AGameMode). If the new GameMode’s class is a subclass of AGameMode and its PlayerController class is the same as the previous, no data copying is done, as the same Actor persists. The data copying procedure takes place on the server.

Usage

  • Persist player-specific data between levels upon seamless travels.
  • Persist same whole PlayerController and PlayerCameraManager Actor instances between levels upon seamless travels.

Example Usage

Let’s take a quick look at the documentation of the function that does the data keeping:

GameModeBase.h

/**
 * Used to swap a viewport/connection's PlayerControllers when seamless traveling and the new GameMode's
 * controller class is different than the previous
 * includes network handling
 * @param OldPC - the old PC that should be discarded
 * @param NewPC - the new PC that should be used for the player
 */
virtual void SwapPlayerControllers(APlayerController* OldPC, APlayerController* NewPC);

Taking a quick look at this function implementation, we can tell what properties are being preserved:

GameModeBase.cpp

void AGameModeBase::SwapPlayerControllers(APlayerController*OldPC, APlayerController* NewPC)
{
    if (IsValid(OldPC) && IsValid(NewPC) && OldPC->Player != nullptr)
    {
        // move the Player to the new PC
        UPlayer* Player = OldPC->Player;
        NewPC->NetPlayerIndex = OldPC->NetPlayerIndex; //@warning: critical that this is first as SetPlayer() may trigger RPCs
        NewPC->NetConnection = OldPC->NetConnection;
        NewPC->SetReplicates(OldPC->GetIsReplicated());
        NewPC->SetPlayer(Player);
        NewPC->CopyRemoteRoleFrom(OldPC);

        K2_OnSwapPlayerControllers(OldPC, NewPC);

        ...
    }

    ...
}

Notice that the native version function above calls the following Blueprint version:

GameModeBase.h

/** Called when a PlayerController is swapped to a new one during seamless travel */
UFUNCTION(BlueprintImplementableEvent, Category=Game, meta=(DisplayName="OnSwapPlayerControllers", ScriptName="OnSwapPlayerControllers"))
void K2_OnSwapPlayerControllers(APlayerController* OldPC, APlayerController* NewPC);

Note: The functions above have to be overridden in the GameMode class of the destination level.

5. GetSeamlessTravelActorList

There are two versions of this function, in two different classes: GameMode and PlayerController. Let’s look at the former’s documentation:

GameModeBase.h

/**
 * called on server during seamless level transitions to get the list of Actors that should be moved into the new level
 * PlayerControllers, Role < ROLE_Authority Actors, and any non-Actors that are inside an Actor that is in the list
 * (i.e. Object.Outer == Actor in the list)
 * are all automatically moved regardless of whether they're included here
 * only dynamic actors in the PersistentLevel may be moved (this includes all actors spawned during gameplay)
 * this is called for both parts of the transition because actors might change while in the middle (e.g. players might join or leave the game)
 * @see also PlayerController::GetSeamlessTravelActorList() (the function that's called on clients)
 * @param bToTransition true if we are going from old level to transition map, false if we are going from transition map to new level
 * @param ActorList (out) list of actors to maintain
 */
virtual void GetSeamlessTravelActorList(bool bToTransition, TArray<AActor*>& ActorList);

We have already seen that it’s used to persist Objects to transition and destination maps, as it will be called twice, once when the transition map is reached, and again when the destination map is reached. It is guaranteed that Objects won’t be garbage collected during the whole process.

Saving & Retrieving Data

What makes this option special, is that you don’t need a third-party Object to save the data for the Actor in question. You neither even need to copy any data (as long as you don’t destroy the Actor manually), as the Actor itself persists with little to no effort. However, there is a good chance the Object which has a pointer to our persistent spawned Actor isn’t persistent, i.e. is going to be destroyed and garbage collected, resulting in a loss of the direct reference to our persistent Actor. Luckily, we do either TActorRange<PersistentActorClass> in native code, or GetAllActorsOfClass in Blueprint, to find our persistent Actor again, and reestablish the linkage. It doesn’t matter how early we try to find it in the new level, it’s going to be there, making it ideal for initialization order too.

Usage

  • Truly persist Objects with little to no effort.
  • Great for initialization order.

AGameModeBase:: GetSeamlessTravelActorList

  • Persist server-side world-state Objects between levels upon seamless travels.

APlayerController:: GetSeamlessTravelActorList

  • Persist local-player (usually client-side) related Objects between levels upon seamless travels.

Note: The PlayerController function version can be called server-side as we’ve seen earlier, in case of a listen-server player.

Example Usage

Say we want to store information about each team playing the match. The Actor that generally stores match state info is GameState. However, what if we want this info to persist between levels, so we can reward players based on that info before a new match starts. Also what if the teams were imbalanced and we want our balancing system to automatically balance them before a new match starts.

We already mentioned why we shouldn’t persist GameState to destination map. Instead, we will make a similar replicated, always relevant, singleton Actor:

TeamSetup.h

USTRUCT()
struct FTeamInfo
{
    GENERATED_BODY()

    UPROPERTY()
    int32 TeamId;

    UPROPERTY()
    FText Name;
    
    UPROPERTY()
    int32 Score;

    void AddScore(int32 InScore)
    {
        Score += InScore;
    }
};

UCLASS()
class ATeamSetup : public AInfo
{
    GENERATED_BODY()

public:

    ATeamSetup(const FObjectInitializer& ObjectInitializer = FObjectInitializer::Get());

    UPROPERTY(Transient)
    TArray<FTeamInfo> Teams;
};

TeamSetup.cpp

ATeamSetup::ATeamSetup(const FObjectInitializer& ObjectInitializer)
    : Super(ObjectInitializer.DoNotCreateDefaultSubobject(TEXT("Sprite")))
{
    bReplicates = true;
    bAlwaysRelevant = true;
}

We will end up having two GameModes: ALobbyGameMode and ACombatGameMode. A good practice is to subclass both classes to ABaseGameMode, which has shared stuff there, including what needs to persist. In our case we will make ALobbyGameMode spawn it, and cache a pointer to it:

BaseGameMode.h

/** Used to setup teams and replicate teams related info to all clients */
UPROPERTY(Transient)
TObjectPtr<ATeamSetup> TeamSetup;

virtual void GetSeamlessTravelActorList(bool bToTransition, TArray<AActor*>& ActorList) override;

BaseGameMode.cpp

void ABaseGameMode::GetSeamlessTravelActorList(bool bToTransition, TArray<AActor*>& ActorList)
{
    Super::GetSeamlessTravelActorList(bToTransition, ActorList);

    ActorList.Add(TeamSetup);
}

BaseGameMode.cpp

void ABaseGameMode::GetSeamlessTravelActorList(bool bToTransition, TArray<AActor*>& ActorList)
{
    Super::GetSeamlessTravelActorList(bToTransition, ActorList);

    ActorList.Add(TeamSetup);
}

LobbyGameMode.h

UCLASS()
class ALobbyGameMode : public AGameMode
{
    GENERATED_BODY()

public:

    virtual void PreInitializeComponents() override;
};

LobbyGameMode.cpp

void ALobbyGameMode::PreInitializeComponents()
{
    Super::PreInitializeComponents();

    FActorSpawnParameters SpawnInfo;
    SpawnInfo.Instigator = GetInstigator();
    SpawnInfo.ObjectFlags |= RF_Transient;  // We never want to save team setups into the map

    UWorld* World = GetWorld();
    TeamSetup = World->SpawnActor<ATeamSetup>(ATeamSetup::StaticClass(), SpawnInfo);
}

In order for our TeamSetup Actor to be easily accessible by all, we will pass it to ACombatGameState, which is replicated and always relevant to all clients. As we noted earlier, ALobbyGameMode has a pointer to the Actor we’re persisting, though it doesn’t persist to the destination map, which results in a loss of the direct reference to TeamSetup Actor. To reestablish the linkage, we’ll have to find it again:

CombatGameMode.h

UCLASS()
class ACombatGameMode : public ABaseGameMode
{
    GENERATED_BODY()

public:
    /**
     * Initialize the GameState actor with default settings
     * called during PreInitializeComponents() of the GameMode after a GameState has been spawned
     * as well as during Reset()
     */
    virtual void InitGameState() override;
}

CombatGameMode.cpp

void ACombatGameMode::InitGameState()
{
    Super::InitGameState();

    for (ATeamSetup* MyTeamSetup : TActorRange<ATeamSetup>(GetWorld()))
    {
        TeamSetup = MyTeamSetup; // reestablish the linkage

        if(ACombatGameState* GS = GetGameState<ABTGameState>())
        {
            GS->TeamSetup = TeamSetup; // cache it in GameState so it's easily accessible client-side
        }
    }
}

Note that we are finding the Actor before the new GameMode has instantiated, so it can be found way before BeginPlay() is called, making it great for initialization order, so it’s just there, no matter how early we try to find it.

Persistent HUD Example

Coming Soon:tm:

6. SaveGame

This class acts as a base class for a save game Object that can be used to save state about the game.

Where does this Class Exist?

This class is locally created, i.e., created wherever you save your data. Therefore, it can exist on both server and client, though it has no support for any kind of replication.

Saving & Retrieving Data

A SaveGame Object/file is saved directly to your disk, making it a great class to save data that should persist a game shutdown (which marks the end of GameInstance’s lifetime and any subsystems that have a similar lifetime). Depending on your game size, you might have multiple SaveGame Objects, so it becomes tricky to keep track of them, and if not managed properly, it becomes quite a mess. Therefore, the GameInstance can come in handy as a manager for all of our SaveGame Objects. If you feel that can bloat your GameInstance class you can opt for a GameInstance subsystem. Both options are great, due to the fact that they are alive as long as the game application is running, and they are easily accessible from almost anywhere. Data can be saved upon traveling or disconnecting, and retrieved/loaded when travel is finished or on reconnection.

Usage

  • Save either player-specific or world-state data that should persist a game exit.
  • Save game-user settings (though usually they are saved in specialized file configs.

Example Usage

There is a plethora of examples I stumbled across, though here are the ones I suggest:

7. Game Options String

While the previous methods revolved around Objects, this one isn’t of such kind.

We have already come to mention the options string, which is part of the so called URL, in the context of Travel Types. You can already tell we can make good use of it, to persist data upon traveling.

Remember: Loading into the game is a travel, specifically a hard one.

URL Structure

Here are the parameters a URL consists of:

EngineBaseTypes.h

// URL structure.
USTRUCT()
struct ENGINE_API FURL
{
    GENERATED_USTRUCT_BODY()

    // Protocol, i.e. "unreal" or "http".
    UPROPERTY()
    FString Protocol;

    // Optional hostname, i.e. "204.157.115.40" or "unreal.epicgames.com", blank if local.
    UPROPERTY()
    FString Host;

    // Optional host port.
    UPROPERTY()
    int32 Port;

    UPROPERTY()
    int32 Valid;

    // Map name, i.e. "SkyCity", default is "Entry".
    UPROPERTY()
    FString Map;

    // Optional place to download Map if client does not possess it
    UPROPERTY()
    FString RedirectURL;

    // Options.
    UPROPERTY()
    TArray<FString> Op;

    // Portal to enter through, default is "".
    UPROPERTY()
    FString Portal;

    // Statics.
    static FUrlConfig UrlConfig;
    static bool bDefaultsInitialized;

    ...
};

URLs can be passed to the executable to force the game to load a specific map upon startup. These can also be used in conjunction with the SERVER or EDITOR modes to run the editor or a server with a specific map. Passing a URL is optional, but must immediately follow the executable name or any mode switch if one is present.

A URL consists of two parts: a map name or server IP address and a series of optional additional parameters. A map name (FURL.Map) can be any map located within the Maps directory. The inclusion of a file extension (.umap) here is optional. To load a map not found in the Maps directory, an absolute path or a relative path from the Maps directory can be used. In this case, the inclusion of the file extension is mandatory. The server IP address (FURL.Host) is a standard four-part IP address consisting of four values between 0 and 255 separated by periods. The additional options (FURL.Op) are specified by appending them to the map name or server IP address. Each option is prefaced by a ? (which acts as a separator), and can be set a value with =, i.e., in the format of: ?option1=value1?option2=value2. Starting an option with - will remove that option from the cached URL options.

Note: Any added meaningless characters (leading blanks, doubled ?) are usually skipped. Other characters are not allowed (double slashes/backslashes, in any combination, nor \?) resulting in an invalid URL (i.e. FURL.Valid = 0). Therefore, you shouldn’t try to fail it, but instead put only what’s needed.

URL Built-in Options

There are two cached URLs for every connection:

  1. World URL (UWorld.URL): Cached loading world URL.
  2. Demo URL (FReplayHelper.DemoURL): Cached replay URL.

The following table has all the built-in options:

Option Description World URL Option? Demo URL Option? Note
Game Alias name for GameMode class to use. :heavy_check_mark: :heavy_check_mark: Overrides default.
Alias name is set up in Project Settings->Maps & Modes->Default Modes->Advanced->Game Mode Class Aliases.
Load If set, loaded world URL won’t be cached. :heavy_check_mark:    
Name Player/bot name to use. :heavy_check_mark:   Has a limit length of 20 characters.
MaxPlayers Maximum number of players allowed by server. :heavy_check_mark:    
MaxSpectators Maximum number of spectators allowed by server. :heavy_check_mark:    
SplitscreenCount Number of split-screen players to allow from one connection. :heavy_check_mark:    
Listen Specifies the server as a listen server. :heavy_check_mark:    
bIsLanMatch Sets whether the multiplayer game is on the local network. :heavy_check_mark:    
bPassthrough Sets whether this net connection is passthrough to IpConnection. :heavy_check_mark:   Uses passthrough sockets.
bUseIPSockets Sets whether to use IP sockets. :heavy_check_mark:    
LAN Used to set lan-related settings. :heavy_check_mark:   Retrieves ConfiguredLanSpeed.
bIsFromInvite Specifies that the player joining was invited. :heavy_check_mark:    
SpectatorOnly Starts the game in spectator mode. :heavy_check_mark:    
SkipSpawnSpectatorController Skips spawning the demo spectator.   :heavy_check_mark:  
DemoRec Demo recording name to use.   :heavy_check_mark:  
DemoFriendlyName Description of replay, preferably human readable.   :heavy_check_mark:  
RecordMapChanges Sets whether demo net driver records map changes/travels.   :heavy_check_mark:  
ReplayStreamerOverride Overrides the default FReplayHelper.ReplayStreamer.   :heavy_check_mark: Value: True/Yes/On vs. False/No/Off.
ReplayStreamerDemoPath Changes the base directory where Demos are stored.   :heavy_check_mark: Value: True/Yes/On vs. False/No/Off.
SkipToLevelIndex Causes current replay to skip to the level with the specified index.   :heavy_check_mark: From the used list of levels.
AsyncLoadWorldOverride Overrides the default async world loading CVarDemoAsyncLoadWorld value.   :heavy_check_mark: Value: True/Yes/On vs. False/No/Off.
LevelPrefixOverride Sets the level ID/PIE instance ID for this net driver to use. :heavy_check_mark:    
AuthTicket Token to use for verification. :heavy_check_mark:    
EncryptionToken Token to make the server start the process of enabling encryption for the connection. :heavy_check_mark:   More info here.
NoTimeouts Ignore timeouts completely. Should be used only in development. :heavy_check_mark:    
Failed Travel failure occurred. :heavy_check_mark:    
Closed Connection to server closed. :heavy_check_mark:    
Restart Reuse the URL from the last time we traveled. :heavy_check_mark:    
Quiet   :heavy_check_mark:    
SeamlessTravel Sets the ServerTravel to be seamless. :heavy_check_mark:   Overrides default (coming soon:tm:).
NoSeamlessTravel Sets the ServerTravel to be non-seamless. :heavy_check_mark:   Overrides default (coming soon:tm:).
Mutator Loads the package for the specified mutator. :heavy_check_mark:   More info here and here.
BugLoc Moves the player to the specified location. :heavy_check_mark:   e.g. BugLoc=(X=1798.8569,Y=475.9513,Z=-8.8500)
BugRot Sets the player to the specified rotation. :heavy_check_mark:   e.g. BugRot=(Pitch=-1978,Yaw=-7197,Roll=0)
CauseEvent Issue cause event after first tick to provide a chance for the game to spawn the player and such. :heavy_check_mark:    
InitialConnectTimeout Overrides NetDriver.InitialConnectTimeout. :heavy_check_mark:    
ConnectTimeout Overrides NetDriver.ConnectTimeout. :heavy_check_mark:    

Note: Like console commands, options are not case-sensitive.

Passing & Parsing Data

No matter what function you choose to travel with, you should be able to pass a URL as parameter. In native code the parameter is referred to as: URL/InURL/Cmd.

In Blueprints there are two main functions that drive traveling:

  • OpenLevel :

OpenLevel

The native function is UGameplayStatics::OpenLevel(). Here’s how it builds the URL :

GameplayStatics.cpp

void UGameplayStatics::OpenLevel(const UObject* WorldContextObject, FName LevelName, bool bAbsolute, FString Options)
{
    ...

    const ETravelType TravelType = (bAbsolute ? TRAVEL_Absolute : TRAVEL_Relative);
    FWorldContext &WorldContext = GEngine->GetWorldContextFromWorldChecked(World);
    FString Cmd = LevelName.ToString();
    if (Options.Len() > 0)
    {
        Cmd += FString(TEXT("?")) + Options;
    }
    FURL TestURL(&WorldContext.LastURL, *Cmd, TravelType);
        
    ...
}

Now we conclude that Options parameter corresponds to FURL.Op. Also you can see there is no need to preface the first option with ? as it’s already done for you.

As noted earlier, bAbsolute = true by default, which resets the Options string we used to have, otherwise it’s carried over from previous level, i.e., the Options string we are passing now is appended to it (more on this enigma below).

  • ExecuteConsoleCommand :

ExecuteConsoleCommand

For traveling, this is how Command usually looks like: <TravelCommand> <MapName><OptionsString>.
So for example: ServerTravel MyMap?Listen?Game=MyGameMode.

Note: OptionsString is appended to MapName. It starts with the character ?.

But wait, is it really sane to have to hard-code the Options string every time we do a travel? Clearly not. Thanks to Cedric for drawing my attention to the following function:

LocalPlayer.h

/** 
 * Retrieves any game-specific login options for this player
 * if this function returns a non-empty string, the returned option or options be added
 * passed in to the level loading and connection code.  Options are in URL format,
 * key=value, with multiple options concatenated together with an & between each key/value pair
 * 
 * @return URL Option or options for this game, Empty string otherwise
 */
virtual FString GetGameLoginOptions() const { return TEXT(""); }

This function lets you centralize the hard-coded Options string, such that all you need to do to retrieve the Options string pending a travel, is to call it. Note that you have to add your own LocalPlayer class, and set it inside Project Settings->General Settings->Default Classes, and then your are good to go at overriding the function.

To parse the options string you have a few functions at your disposal.

Tip: See the previously mentioned, native functions, to do it in natively.

The Enigmas

There were two enigmas I faced along the way:

(1) You might have noticed that the GameMode caches the options string:

GameModeBase.h

/** Save options string and parse it when needed */
UPROPERTY(BlueprintReadOnly, Category=GameMode)
FString OptionsString;

First question that came to my mind is: “Considering the GameMode class exists only on server, how is this string cached per connection?”

When the map loads for the first time, the following function is called:

GameModeBase.cpp

void AGameModeBase::InitGame(const FString& MapName, const FString& Options, FString& ErrorMessage)
{
    ...

    // Save Options for future use
    OptionsString = Options;

    ...
}

Considering this is the only place where AGameModeBase.OptionsString is cached, we conclude:

  • In case of a listen-server setup: The OptionsString belongs to the host player.

For example, say a player is hosting a map, i.e., loading as listen-server. He might want to call the following function with the set parameters:

OpenLevelListen

Considering he’s the one loading the map for the first time, then OptionsString equals the passed parameter Options. Other players joining the map won’t affect it. Then where is their options string cached?

The answer lies in the connection process. Any client attempting to join the server will call AGameModeBase::PreLogin(const FString& Options, ...), which has the option string passed as parameter. One of the further down called functions is the following:

GameModeBase.h

/**
 * Customize incoming player based on URL options
 *
 * @param NewPlayerController player logging in
 * @param UniqueId unique id for this player
 * @param Options URL options that came at login
 *
 */
virtual FString InitNewPlayer(APlayerController* NewPlayerController, const FUniqueNetIdRepl& UniqueId, const FString& Options, const FString& Portal = TEXT(""));

By looking at its implementation, you will understand that for clients, the options string is parsed but not cached. I will leave it you to see how it pareses the URL options.

Note: AGameModeBase::InitNewPlayer() and the login functions (AGameModeBase::PreLogin()/AGameModeBase::Login()) called before it, are called only when hard traveling. AGameModeBase::PreLogin() is called for client connections attempting to join the server. AGameModeBase::Login() is called for each accepted client connection, including the listen-server connection in case such one exists.

  • In case of a dedicated-server setup: The options string belongs to the server. For clients it varies, depending on what travel command they traveled with. For example ServerTravel, travels all clients with the same options string as the server. If you want the ability to pass a different options string you need use either Travel or Open commands.

(2) As we said earlier a few times now, that depending on the travel type, the options string might get reset. What does that really mean?

Let’s look at the example from before: A player is hosting a map, i.e., loading as listen-server. He might want to call the following function with the set parameters:

OpenLevelListen

As you see, bAbsolute = true, meaning the travel type is TRAVEL_Absolute, and we already said that means that the whole last URL is ignored, including the options string. In our case, does that mean the Options string parameter is ignored?

The answer is clearly no, Options isn’t ignored. The reason is that the last URL is the one ignored, and Options is part of the current one we are traveling with. Also it doesn’t make sense for it to be ignored/reset, cause otherwise we won’t be able to host a map at all.

To get the full picture you will have to look at the following struct:

Engine.h

USTRUCT()
struct FWorldContext
{
    GENERATED_USTRUCT_BODY()

    ...

    /** URL to travel to for pending client connect */
    FString TravelURL;

    /** TravelType for pending client connects */
    uint8 TravelType;

    /** URL the last time we traveled */
    UPROPERTY()
    struct FURL LastURL;

    /** last server we connected to (for "reconnect" command) */
    UPROPERTY()
    struct FURL LastRemoteURL;

    ...
};

As can be seen, FWorldContext.LasURL is the last URL, while FWorldContext.TravelURL is the current one (the one the client is traveling with). Documentation about the struct can be found here.

Now say for example, after we traveled to MyMap, we traveled to MyOtherMap this way:

OpenLevelRelative

Notice that this time bAbsolute = false, meaning that the travel is of type TRAVEL_Relative. As mentioned earlier, this means that the last URL is kept (we are still on the same server), and last options string is kept too, which gets prepended to current options string we are traveling with. So the new options string will look like this: ?MaxPlayers=3?Listen?Name=MyAwesomeName.

You might have noticed that that there is a missing ?Listen at the start of the string. The reason being, the engine is preventing us from shooting ourself in the foot, so it removes it:

UnrealEngine.cpp

void UEngine::SetClientTravel( UWorld *InWorld, const TCHAR* NextURL, ETravelType InTravelType )
{
    FWorldContext &Context = GetWorldContextFromWorldChecked(InWorld);

    // set TravelURL.  Will be processed safely on the next tick in UGameEngine::Tick().
    Context.TravelURL    = NextURL;
    Context.TravelType   = InTravelType;

    // Prevent crashing the game by attempting to connect to own listen server
    if ( Context.LastURL.HasOption(TEXT("Listen")) )
    {
        Context.LastURL.RemoveOption(TEXT("Listen"));
    }
}

This function is called for all types of travels (including disconnects, which in fact are travels too), for any client that travels (including client that loads as a host).

Usage

  • Persist primitive data types upon traveling (regardless of the travel type).
  • Pass data to the server when loading for the first time (hard travel).
  • Rapid testing.

Example Usage

  • The following are examples of launching standalone via the command line:

UnrealEngine.cpp

MyGame.exe /Game/Maps/MyMap
UnrealEditor.exe MyGame.uproject /Game/Maps/MyMap?game=MyGameMode -game
UnrealEditor.exe MyGame.uproject /Game/Maps/MyMap?listen -server
MyGame.exe 127.0.0.1
  • The following is an example of a player hosting a map (by pressing 1), and another player joining him (by pressing 2):

HostAndJoin

8. Database

Coming Soon:tm:

Updated: