50 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, while non-seamless will be a blocking call. In addition, in non-seamless traveling the players are disconnected from the server and reconnect when the new map has been loaded, while in seamless traveling the players stay connected but are transferred to a transition map until the next level has been loaded.

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.

Note: In UE4 and UE5 seamless travel isn’t supported yet in PIE (play-in-editor). Therefore, you have to start a standalone (right click your .uproject and Launch game) or have a packaged build. In UE5-main branch, it seems to work in PIE, but you need to enable CVar net.AllowPIESeamlessTravel as it’s disabled by default. Use with caution!

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:

// 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:

Travel Drivers

There are three main native functions that drive traveling: UEngine::Browse(), UWorld::ServerTravel(), and APlayerController::ClientTravel(). Read the official docs to get a better understanding of when to use each.

Note: UWorld::ServerTravel() should be called from server, otherwise, nothing will happen.

In Blueprints, the two functions that drive traveling are:

  • OpenLevel 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(). 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 function and Command parameter be ServerTravel <MapName> or Travel <MapName>(the former is the one usually used as it’s seamless while the latter isn’t) were 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().

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.

GameFramework Actors Creation Cycle

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 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.

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 is 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: GameMode, GameSession and GameNetworkManager 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 proxy on server and client. PlayerController is replicated, existing on server for every proxy, and only on owning client. 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 only on owning client if the latter calls APlayerController::EnableCheats().

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.

Note: I’m not providing you with the full creation call stack neither with the whole actors 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.

Persisting Actors 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 (more below).
  2. Travel to the transition level.
  3. Mark actors that will persist to the final level (more below).
  4. Travel to the final level.

Persistent Actors on Server to Transition Map only

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

  • GameMode
  • GameSession
  • GameState

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 Actors on Server to Destination Map

By default, the following actors 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 UMG UserWidgets
  • Any Actors further added via APlayerController::GetSeamlessTravelActorList() called on listen-server’s PlayerController
  • Any Actors further added via AGameModeBase::GetSeamlessTravelActorList()
  • Any Objects that are inside an Actor that is in the list (i.e. Object.Outer == Actor in the list ActorList)
  • Any Actors that has: Role < ROLE_Authority

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 Actors on Client to Destination Map

By default, the following actors 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 UMG UserWidgets
  • Any Actors further added via APlayerController::GetSeamlessTravelActorList() called on local PlayerController
  • Any Objects that are inside an Actor that is in the list (i.e. Object.Outer == Actor in the list)
  • Any Actors that has: Role < ROLE_Authority

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);
}

Meaning of Persistent Actors

We have already seen that almost all actors, 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 actors 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 actor 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 actors don’t really persist, but the data we choose to keep. However, this is not entirely true. For example, UMG UserWidgets 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 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 persisting actors 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#1654 from Unreal Slackers 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.

/// MyPlayerState.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:

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 what happens when a player logs back in to 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 actors between levels upon seamless travels.

Example Usage

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

/// PlayerController.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:

/// PlayerController.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:

/// PlayerController.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 actors 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 the actors/objects won’t be garbage collected.

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 actors/objects with little to no effort.
  • Great for initialization order.

AGameModeBase::GetSeamlessTravelActorList

  • Persist server-side world-state actors/objects between levels upon seamless travels.

APlayerController::GetSeamlessTravelActorList

  • Persist local-player (usually client-side) related actors/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);
}

/// 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 actor/object classes, 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: When we load 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 characters that make no sense, or add no real value to the options string (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.

Here you can find some of the built-in options to use. (todo: make a table instead)

Note: By default, name option has a limit length of 20 characters.

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 ?.

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:

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:
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: