Lyra Deep Dive - Chapter 3: Experience Lifecycle
This is the third chapter in the Lyra Deep Dive series.
In the previous chapter, we’ve learned about how experiences are defined. In this chapter, we’ll take a deep dive into the lifecycle of an experience.
Lyra Deep Dive Series
Experience Lifecycle
ALyraGameState
automatically adds ULyraExperienceManagerComponent
to itself in its constructor. This component handles the entire lifecycle of an experience.
The lifecycle of an experience begins with ALyraGameMode
on the server calling SetCurrentExperience
and replicating CurrentExperience
to all clients.
The loading process starts immediately on the server. For clients, the loading process starts after CurrentExperience
is replicated.
Function | Target | Outcome |
---|---|---|
SetCurrentExperience |
Server | Set CurrentExperience which is replicated to all clients and call StartExperienceLoad on the server. |
OnRep_CurrentExperience |
Client | Call StartExperienceLoad
|
StartExperienceLoad |
Client & Server | Load experience definition, associated assets, and asset bundles. |
OnExperienceLoadComplete |
Client & Server | Load and activate game feature plugins. |
OnExperienceFullLoadCompleted |
Client & Server | Chaos testing and execute game feature actions. |
EndPlay |
Client & Server | Deactivate and unload game features. |
OnAllActionsDeactivated |
Client & Server | Clear CurrentExperience . |
The LoadState
property reflects the current state of the experience. The following diagram shows the transition between states:
Let’s take a closer look at each stage of the lifecycle.
Replication
ULyraExperienceManagerComponent
has a function SetCurrentExperience
.
// File: LyraExperienceManagerComponent.h
void SetCurrentExperience(FPrimaryAssetId ExperienceId);
// File: LyraExperienceManagerComponent.cpp
void ULyraExperienceManagerComponent::SetCurrentExperience(FPrimaryAssetId ExperienceId)
{
ULyraAssetManager& AssetManager = ULyraAssetManager::Get();
FSoftObjectPath AssetPath = AssetManager.GetPrimaryAssetPath(ExperienceId);
TSubclassOf<ULyraExperienceDefinition> AssetClass = Cast<UClass>(AssetPath.TryLoad());
check(AssetClass);
const ULyraExperienceDefinition* Experience = GetDefault<ULyraExperienceDefinition>(AssetClass);
check(Experience != nullptr);
check(CurrentExperience == nullptr);
CurrentExperience = Experience;
StartExperienceLoad();
}
In SetCurrentExperience
, the server does the following:
- Synchronously load the experience definition.
- Verify the experience definition was loaded successfully.
- Set CurrentExperience which will trigger replication to all clients.
- Call
StartExperienceLoad
to start the experience lifecycle on the server.
// File: LyraExperienceManagerComponent.h
UPROPERTY(ReplicatedUsing=OnRep_CurrentExperience)
TObjectPtr<const ULyraExperienceDefinition> CurrentExperience;
UFUNCTION()
void OnRep_CurrentExperience();
// File: LyraExperienceManagerComponent.cpp
void ULyraExperienceManagerComponent::OnRep_CurrentExperience()
{
StartExperienceLoad();
}
OnRep_CurrentExperience
is executed on all clients when CurrentExperience
is replicated from the server. This function then calls StartExperienceLoad
to start the experience lifecycle on the client.
Stage 1: Load Experience Definition
The experience definition and all associated assets* are asynchronously loaded in this stage.
*Only assets that are directly referenced by the experience definition are loaded here like, for example, HUD widgets in the Add Widgets action. All other assets in game feature plugins are loaded in the next stage.
StartExperienceLoad
begins by populating BundleAssetList
with a set of primary asset IDs including the experience definition itself and any linked action sets.
// Function: StartExperienceLoad()
TSet<FPrimaryAssetId> BundleAssetList;
BundleAssetList.Add(CurrentExperience->GetPrimaryAssetId());
for (const TObjectPtr<ULyraExperienceActionSet>& ActionSet : CurrentExperience->ActionSets)
{
if (ActionSet != nullptr)
{
BundleAssetList.Add(ActionSet->GetPrimaryAssetId());
}
}
Next, it determines the asset bundles to load.
// Function: StartExperienceLoad()
TArray<FName> BundlesToLoad;
BundlesToLoad.Add(FLyraBundles::Equipped);
const ENetMode OwnerNetMode = GetOwner()->GetNetMode();
const bool bLoadClient = GIsEditor || (OwnerNetMode != NM_DedicatedServer);
const bool bLoadServer = GIsEditor || (OwnerNetMode != NM_Client);
if (bLoadClient)
{
BundlesToLoad.Add(UGameFeaturesSubsystemSettings::LoadStateClient);
}
if (bLoadServer)
{
BundlesToLoad.Add(UGameFeaturesSubsystemSettings::LoadStateServer);
}
Asset Bundle Name | Purpose | Used By |
---|---|---|
Equipped |
Assets in this bundle are always loaded. | None (as of UE 5.1) |
Client |
Assets to load on clients or PIE. | HUD Widgets, Input Configs, and Ability Sets |
Server |
Assets to load on dedicated servers or PIE. | Input Configs and Ability Sets |
The assets and asset bundles are loaded with a call to ChangeBundleStateForPrimaryAssets
. You may notice that the async handle for this operation, BundleLoadHandle
, is then combined with RawLoadHandle
. LoadAssetList
loads all secondary assets added to RawAssetList
. However, this is unused right now and you won’t need it.
// Function: StartExperienceLoad()
const TSharedPtr<FStreamableHandle> BundleLoadHandle = AssetManager.ChangeBundleStateForPrimaryAssets(BundleAssetList.Array(), BundlesToLoad, {}, false, FStreamableDelegate(), FStreamableManager::AsyncLoadHighPriority);
const TSharedPtr<FStreamableHandle> RawLoadHandle = AssetManager.LoadAssetList(RawAssetList.Array(), FStreamableDelegate(), FStreamableManager::AsyncLoadHighPriority, TEXT("StartExperienceLoad()"));
// If both async loads are running, combine them
TSharedPtr<FStreamableHandle> Handle = nullptr;
if (BundleLoadHandle.IsValid() && RawLoadHandle.IsValid())
{
Handle = AssetManager.GetStreamableManager().CreateCombinedHandle({ BundleLoadHandle, RawLoadHandle });
}
else
{
Handle = BundleLoadHandle.IsValid() ? BundleLoadHandle : RawLoadHandle;
}
FStreamableDelegate OnAssetsLoadedDelegate = FStreamableDelegate::CreateUObject(this, &ThisClass::OnExperienceLoadComplete);
if (!Handle.IsValid() || Handle->HasLoadCompleted())
{
// Assets were already loaded, call the delegate now
FStreamableHandle::ExecuteDelegate(OnAssetsLoadedDelegate);
}
else
{
Handle->BindCompleteDelegate(OnAssetsLoadedDelegate);
Handle->BindCancelDelegate(FStreamableDelegate::CreateLambda([OnAssetsLoadedDelegate]()
{
OnAssetsLoadedDelegate.ExecuteIfBound();
}));
}
When the async load operation is complete, it calls OnExperienceLoadComplete
which brings us to the next stage.
At the end of StartExperienceLoad
, certain assets may be preloaded without blocking the game. This is also unused at this time.
Stage 2: Load Game Features
Game features are loaded and activated in this stage.
OnExperienceLoadComplete
begins by collecting all game feature plugins from the experience definition and all linked action sets, filtering out duplicates and invalid names.
// Function: OnExperienceLoadComplete()
GameFeaturePluginURLs.Reset();
auto CollectGameFeaturePluginURLs = [This=this](const UPrimaryDataAsset* Context, const TArray<FString>& FeaturePluginList)
{
for (const FString& PluginName : FeaturePluginList)
{
FString PluginURL;
if (UGameFeaturesSubsystem::Get().GetPluginURLByName(PluginName, /*out*/ PluginURL))
{
This->GameFeaturePluginURLs.AddUnique(PluginURL);
}
else
{
ensureMsgf(false, TEXT("OnExperienceLoadComplete failed to find plugin URL from PluginName %s for experience %s - fix data, ignoring for this run"), *PluginName, *Context->GetPrimaryAssetId().ToString());
}
}
};
CollectGameFeaturePluginURLs(CurrentExperience, CurrentExperience->GameFeaturesToEnable);
for (const TObjectPtr<ULyraExperienceActionSet>& ActionSet : CurrentExperience->ActionSets)
{
if (ActionSet != nullptr)
{
CollectGameFeaturePluginURLs(ActionSet, ActionSet->GameFeaturesToEnable);
}
}
When there is at least one valid game feature, it asynchronously loads and activates each one of them using a counter NumGameFeaturePluginsLoading
to keep track of the number of plugins left to load.
// Function: OnExperienceLoadComplete()
NumGameFeaturePluginsLoading = GameFeaturePluginURLs.Num();
if (NumGameFeaturePluginsLoading > 0)
{
LoadState = ELyraExperienceLoadState::LoadingGameFeatures;
for (const FString& PluginURL : GameFeaturePluginURLs)
{
ULyraExperienceManager::NotifyOfPluginActivation(PluginURL);
UGameFeaturesSubsystem::Get().LoadAndActivateGameFeaturePlugin(PluginURL, FGameFeaturePluginLoadComplete::CreateUObject(this, &ThisClass::OnGameFeaturePluginLoadComplete));
}
}
else
{
OnExperienceFullLoadCompleted();
}
When a game feature is activated, it invokes OnGameFeaturePluginLoadComplete
which decreases the counter.
void ULyraExperienceManagerComponent::OnGameFeaturePluginLoadComplete(const UE::GameFeatures::FResult& Result)
{
// decrement the number of plugins that are loading
NumGameFeaturePluginsLoading--;
if (NumGameFeaturePluginsLoading == 0)
{
OnExperienceFullLoadCompleted();
}
}
OnExperienceFullLoadCompleted
is called when there are no more game features left to load which brings us to the next stage.
Stage 3. Chaos Testing
This stage is optional and disabled by default. When enabled, a random delay is added to the load time here. This can help you test your game by having staggered client readiness.
To configure chaos testing, use these console variables:
CVar | Description |
---|---|
lyra.chaos.ExperienceDelayLoad.MinSecs |
This value (in seconds) will be added as a delay of load completion of the experience (along with the random value lyra.chaos.ExperienceDelayLoad.RandomSecs ) |
lyra.chaos.ExperienceDelayLoad.RandomSecs |
A random amount of time between 0 and this value (in seconds) will be added as a delay of load completion of the experience (along with the fixed value lyra.chaos.ExperienceDelayLoad.MinSecs ) |
Stage 4. Execute Game Feature Actions
Game feature actions are executed in the order as they appear in the experience definition and then each action set.
FGameFeatureActivatingContext Context;
// Only apply to our specific world context if set
const FWorldContext* ExistingWorldContext = GEngine->GetWorldContextFromWorld(GetWorld());
if (ExistingWorldContext)
{
Context.SetRequiredWorldContextHandle(ExistingWorldContext->ContextHandle);
}
auto ActivateListOfActions = [&Context](const TArray<UGameFeatureAction*>& ActionList)
{
for (UGameFeatureAction* Action : ActionList)
{
if (Action != nullptr)
{
Action->OnGameFeatureRegistering();
Action->OnGameFeatureLoading();
Action->OnGameFeatureActivating(Context);
}
}
};
ActivateListOfActions(CurrentExperience->Actions);
for (const TObjectPtr<ULyraExperienceActionSet>& ActionSet : CurrentExperience->ActionSets)
{
if (ActionSet != nullptr)
{
ActivateListOfActions(ActionSet->Actions);
}
}
Finally, the experience is fully loaded at this point.
The game is notified that the experience has finished loading via the OnExperienceLoaded
delegates.
OnExperienceLoaded_HighPriority.Broadcast(CurrentExperience);
OnExperienceLoaded_HighPriority.Clear();
OnExperienceLoaded.Broadcast(CurrentExperience);
OnExperienceLoaded.Clear();
OnExperienceLoaded_LowPriority.Broadcast(CurrentExperience);
OnExperienceLoaded_LowPriority.Clear();
You may notice that it clears all delegates after broadcasting. This is because the CallOrRegister_OnExperienceLoaded
functions for all priorities check whether the experience has been loaded and executes the callback immediately if so. Otherwise, it adds to the delegate to be called later.
Delegate | Used For |
---|---|
OnExperienceLoaded_HighPriority |
Frontend (ULyraFrontendStateComponent ) and Team Creation (ULyraTeamCreationComponent ) |
OnExperienceLoaded |
Spawning Pawns (ALyraGameMode and ALyraPlayerState ) |
OnExperienceLoaded_LowPriority |
Bots (ULyraBotCreationComponent ) |
Stage 5. Deactivate Experience
When EndPlay
on the component is triggered, all loaded game feature plugins are asynchronously deactivated.
At this time, Lyra does not unload game features after they’ve been deactivated. Ideally, it should’ve called UGameFeaturesSubsystem::UnloadGameFeaturePlugin
at some point in the deactivation logic. Maybe we’ll see that in a future version.