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:
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 | ||||
Partial | ||||
Absolute |
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 functionUGameplayStatics::OpenLevel()
, which callsUEngine::SetClientTravel()
. On the next tick,UGameEngine::Tick()
callsUEngine::Browse()
, which callsUEngine::LoadMap()
. By defaultbAbsolute = 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 beServerTravel <MapName>
orTravel <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()
andUEngine::HandleTravelCommand()
respectively. Another command that you can use isOpen <MapName>
which opens the specified map, without passing previously set options string, as it’s an Absolute travel. It corresponds to the native functionUEngine::HandleOpenCommand()
, which callsUEngine::SetClientTravel()
. On the next tick,UGameEngine::Tick()
callsUEngine::Browse()
, which callsUEngine::LoadMap()
. It’s always an Absolute travel.
Luckily those commands are autocompleted when typed inside the Console Command at runtime:
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:
-
GameInstance: In Standalone is created once on game start inside
UGameEngine::Init()
, and in PIE is created for each PIE instance insideUEditorEngine::CreateInnerProcessPIEGameInstance()
. Same GameInstance is set to be used in the new loaded level insideUEngine::LoadMap()
. -
GameMode: Created by GameInstance when server loads the map inside
UGameInstance::CreateGameModeForURL()
, called byUWorld::SetGameMode()
, called byUEngine::LoadMap()
. -
GameSession: Created by GameMode inside
AGameModeBase::InitGame()
. -
GameState: Created by GameMode inside
AGameModeBase::PreInitializeComponents()
. -
GameNetworkManager: Created by GameMode inside
AGameModeBase::PreInitializeComponents()
. -
PlayerController: Created by GameMode, either after a successful login inside
AGameModeBase::SpawnPlayerController()
, called byAGameModeBase::Login()
, called byUWorld::SpawnPlayActor()
in case of hard travel. However, in case of seamless travel: If the new GameMode’s class is a subclass ofAGameMode
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 insideAGameMode(Base)::HandleSeamlessTravelPlayer()
. -
SpectatorPawn: Created by PlayerController inside
APlayerController::SpawnSpectatorPawn()
, called byAPlayerController::BeginSpectatingState()
, which is either called byAPlayerController::ReceivedPlayer()
, called byAPlayerController::SetPlayer()
, called byUWorld::SpawnPlayActor()
in case of hard travel, or called byAPlayerController::ChangeState()
, called byAGameMode(Base)::InitSeamlessTravelPlayer()
, called byAGameMode(Base)::HandleSeamlessTravelPlayer()
in case of seamless travel.
It’s destroyed (as soon as a Pawn is possessed) insideAPlayerController::DestroySpectatorPawn()
, called byAPlayerController::ChangeState()
, called byAPlayerController::OnPossess()
, called byAController::Possess()
, called byAGameModeBase::FinishRestartPlayer()
. -
PlayerState: Created by PlayerController inside
AController::InitPlayerState()
, called byAPlayerController::PostInitializeComponents()
in case of player, and created by AIController insideAController::InitPlayerState()
, called byAAIController::PostInitializeComponents()
. -
PlayerCameraManager: Created by PlayerController inside
APlayerController::SpawnPlayerCameraManager()
called byAPlayerController::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 insideAPlayerController::PostSeamlessTravel()
. -
CheatManager: Created by PlayerController inside
APlayerController::AddCheats()
, called by eitherAPlayerController::PostInitializeComponents()
in case of PIE/single-player, orEnableCheats()
in any other case but a shipping build. -
HUD: Created by PlayerController inside
APlayerController::ClientSetHUD()
, called byAGameModeBase::InitializeHUDForPlayer()
, called byAGameModeBase::GenericPlayerInitialization()
which is called for both seamless and hard travels. In case of seamless travel, if the new GameMode’s class is a subclass ofAGameMode
and its PlayerController class is the same as the previous, the old HUD will be valid insideAPlayerController::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). -
Pawn: Created by GameMode inside
AGameModeBase::RestartPlayer()
, called byAGameMode(Base)::HandleStartingNewPlayer()
, called by eitherAGameMode(Base)::PostLogin()
in case of hard travel, orAGameMode(Base)::HandleSeamlessTravelPlayer()
in case of seamless travel. -
AIController: Created by AI Pawn inside
APawn::SpawnDefaultController()
, called byAPawn::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
- Mark Actors that will persist to the transition level (inner non-Actor Objects will automatically persist too).
- Travel to the transition level.
- Mark Actors that will persist to the final level (inner non-Actor Objects will automatically persist too).
- 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:
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:
- There should be no reason to persist runtime data in GameMode, as this class defines the game rules which are set at compile time.
- GameMode class usually varies between two different levels, so keeping the same Actor is a bad idea.
-
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); }
- 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 listActorList
) - 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:
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:
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:
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:
-
Mimic what the engine does for hard travel: Override
UUserWidget::AddToScreen()
(in a customUserWidgetBase
class, that every UserWidget inherits from) and listen for the delegateFWorldDelegates::OnSeamlessTravelStart
, which calls a customUserWidgetBase::OnSeamlessTravelStart()
that calls an overriddenUUserWidgetBase::RemoveFromParent()
. -
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 callUUserWidget::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 :
- His Pawn is unpossessed from the PlayerController and destroyed inside
APlayerController::PawnLeavingGame()
. SpectatorPawn, HUD and PlayerCameraManager are all destroyed too. -
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; }
- 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 hisUniqueId
orSavedNetworkAddress
is lost). SettingInactivePlayerStateLifeSpan
to 0 will clear the timer, and the stored copy PlayerState won’t be destroyed. The inactive PlayerState is added toAGameMode.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 thatAGameMode.MaxInactivePlayers
determines the maximum number of disconnected players before the older ones are kicked out. - The original PlayerState is then destroyed inside
APlayerState::OnDeactivated()
, called byAPlayerController::CleanupPlayerState()
, which in turn removes it fromAGameStateBase.PlayerArray
(array of all active PlayerStates) insideAPlayerState::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.
(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.
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:
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:
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:
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:
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:
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:
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 :
- A new PlayerController and Pawn (which becomes possessed by the former) are recreated for him by GameMode.
- A new PlayerState, PlayerCameraManager and HUD are recreated for him by PlayerController.
- 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. - 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 toAGameMode::OverridePlayerState()
, which in turn passes it at some point toAPlayerState::ReceiveOverrideWith()
which its documentation suggests the following:
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:
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:
-
DISCONNECT: Disconnects the client from the current game/server. It corresponds to the native function
UEngine::HandleDisconnectCommand()
, which callsUEngine::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) andCommand
parameter be eitherDISCONNECT
orRECONNECT
. -
UGameInstance::Exec()
function andCmd
parameter be eitherDISCONNECT
orRECONNECT
.
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:
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:
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:
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.
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:
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:
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:
Taking a quick look at this function implementation, we can tell what properties are being preserved:
Notice that the native version function above calls the following Blueprint version:
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:
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:
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:
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:
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
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:
- Saving and Loading Your Game (Official Docs)
- Unreal Engine C++ Save System (Tom Looman Tutorial)
- SPUD: Steve’s Persistent Unreal Data library (Complete Save System Plugin)
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:
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:
-
World URL (
UWorld.URL
): Cached loading world URL. -
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. | 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. | |||
Name | Player/bot name to use. | Has a limit length of 20 characters. | ||
MaxPlayers | Maximum number of players allowed by server. | |||
MaxSpectators | Maximum number of spectators allowed by server. | |||
SplitscreenCount | Number of split-screen players to allow from one connection. | |||
Listen | Specifies the server as a listen server. | |||
bIsLanMatch | Sets whether the multiplayer game is on the local network. | |||
bPassthrough | Sets whether this net connection is passthrough to IpConnection. | Uses passthrough sockets. | ||
bUseIPSockets | Sets whether to use IP sockets. | |||
LAN | Used to set lan-related settings. | Retrieves ConfiguredLanSpeed . |
||
bIsFromInvite | Specifies that the player joining was invited. | |||
SpectatorOnly | Starts the game in spectator mode. | |||
SkipSpawnSpectatorController | Skips spawning the demo spectator. | |||
DemoRec | Demo recording name to use. | |||
DemoFriendlyName | Description of replay, preferably human readable. | |||
RecordMapChanges | Sets whether demo net driver records map changes/travels. | |||
ReplayStreamerOverride | Overrides the default FReplayHelper.ReplayStreamer . |
Value: True/Yes/On vs. False/No/Off . |
||
ReplayStreamerDemoPath | Changes the base directory where Demos are stored. | Value: True/Yes/On vs. False/No/Off . |
||
SkipToLevelIndex | Causes current replay to skip to the level with the specified index. | From the used list of levels. | ||
AsyncLoadWorldOverride | Overrides the default async world loading CVarDemoAsyncLoadWorld value. |
Value: True/Yes/On vs. False/No/Off . |
||
LevelPrefixOverride | Sets the level ID/PIE instance ID for this net driver to use. | |||
AuthTicket | Token to use for verification. | |||
EncryptionToken | Token to make the server start the process of enabling encryption for the connection. | More info here. | ||
NoTimeouts | Ignore timeouts completely. Should be used only in development. | |||
Failed | Travel failure occurred. | |||
Closed | Connection to server closed. | |||
Restart | Reuse the URL from the last time we traveled. | |||
Quiet | ||||
SeamlessTravel | Sets the ServerTravel to be seamless. |
Overrides default (coming soon). | ||
NoSeamlessTravel | Sets the ServerTravel to be non-seamless. |
Overrides default (coming soon). | ||
Mutator | Loads the package for the specified mutator. | More info here and here. | ||
BugLoc | Moves the player to the specified location. | e.g. BugLoc=(X=1798.8569,Y=475.9513,Z=-8.8500)
|
||
BugRot | Sets the player to the specified rotation. | 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. | |||
InitialConnectTimeout | Overrides NetDriver.InitialConnectTimeout . |
|||
ConnectTimeout | Overrides NetDriver.ConnectTimeout . |
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 :
The native function is UGameplayStatics::OpenLevel()
. Here’s how it builds the URL :
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 :
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:
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:
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:
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:
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:
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 eitherTravel
orOpen
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:
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:
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:
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:
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:
- The following is an example of a player hosting a map (by pressing 1), and another player joining him (by pressing 2):
8. Database
Coming Soon