Written for Unreal Engine 5.3 C++

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.

ALyraGameMode
ALyraGameState
ULyraExperienceManagerComponent

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.

Replicate to clients
ALyraGameMode
SetCurrentExperience
OnRep_CurrentExperience
StartExperienceLoad
OnExperienceLoadComplete
OnExperienceFullLoadCompleted
EndPlay
OnAllActionsDeactivated
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:

Has Game Features
No Game Features
Chaos Testing Enabled
Chaos Testing Disabled
Unloaded
Loading
LoadingGameFeatures
LoadingChaosTestingDelay
ExecutingActions
Loaded
Deactivating
Unloaded

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:

  1. Synchronously load the experience definition.
  2. Verify the experience definition was loaded successfully.
  3. Set CurrentExperience which will trigger replication to all clients.
  4. 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.