[UE5.4/5.5] C++ Guide on how to load classic map and level instances. Looking for Feedback.

NOTE: This guide NOT for Level Streaming, NOT for World Partition and NOT for dynamic Level Instances.

I’ve had quite the dreadful time trying to figure out when a regular level is loaded. We’re talking a classic non streaming, non world partition level. At first sight, it might seem easy. Just use OpenLevel(). But that just loads it into memory. You can’t use the level right away to continue setup and gameplay. You need to let the engine tick for a while until everything is ready. How do we know when the level is ready to use? That’s what we’ll go over in this guide.

I’m looking for feedback on anything that could be done easier. It’s possible there’s an event that does all this and I’ve missed it. Please indicate anything you think could make this guide better. I’m thinking of converting it to a proper guide and submitting it later.

The question of this post is this:
Is there a better/simpler way to know when a level is loaded?

Be warned this is quite long. It goes over loading classic maps and level instances already in the map.

C++ Guide on how to load normal levels (NO world partition) and level instances.

How to start loading a level?

The first level will load automatically. Nothing needs to be done there to initiate loading. When we load another level, it will completely replace the current level. Before getting into that, how do we start to load another level?

The easiest way is to have a soft pointer to your level like so:

  UPROPERTY(EditAnywhere, BlueprintReadWrite)
  TSoftObjectPtr<UWorld> LevelToLoad;

You can set it in the editor.

Then you can start loading the level with this:

UGameplayStatics::OpenLevelBySoftObjectPtr(GetWorld(), this->LevelToLoad);

You can also open by name with this:

UGameplayStatics::OpenLevel(GetWorld(), FName("Level1"));

In the rest of this guide, we will refer to both/either of these functions as OpenLevel for convenience.

There are already several problems here. The first problem is that you need to be in a UObject that has implemented GetWorld(). Not all UObjects implement it. Actors DO implement it, but this is dangerous to open a new level from an actor because all actors are immediately destroyed including itself. Yes, the code will continue to run, but everything is set to be garbage collected.

The second problem is that opening a level is not a blocking operation. At least, not completely.
The level will not be loaded when OpenLevel returns.

That brings up the main question of this guide.

How do you know when a level is loaded?

That’s the million dollar question.

Some easy methods involve using the BeginPlay event in the Level Blueprint.

That might sound good at first, but BeginPlay doesn’t always trigger. If you reload a level,
it won’t always trigger for example.

Later on, we’ll discuss knowing when a level instance is loaded. The Level’s BeginPlay doesn’t wait for level instances that are already in the level. So BeginPlay won’t help you there.

Let’s deal with the simpler task first. How to know when opening a level is done (without level instances for now).

From what I’ve seen, this is what opening a level does.

  1. It destroys all actors in the current level, if any.
  2. It loads the new level in memory.

That’s it. It doesn’t do any more than that. BOTH levels will be in memory at the same time and nothing in either level is usable.

The engine needs to have LOTS and LOTS of ticks to complete the loading process. What it does afterwards is (order might be wrong):

  1. Create a proper level for the new actors.
  2. Initialize instances of the actors.
  3. Call BeginPlay on actors
  4. Display the actors (if visible).

As a side-note, construction scripts are NOT called on in-level actors unless you’re running in the editor.

Here we see the first case of a Level and Map being different things. A Map is the same thing a UWorld. It contains everything you see in your game. You usually can only have one. However, you will have several levels. Up until now, we’ve been using Level, Map and World interchangeably. Levels are now something a bit different.

You will have a level for each of these:

  1. One level for all the actors in the root folder of the map.
  2. One level for each folder in the map.
  3. One level for each streaming level (sub levels).
  4. One level for each level instance.

As you can see, levels are NOT the same as a map/world. For most people there is no difference. But once we get to level instances, this difference will be critical.

Let’s resolve the first issue. We need a way to get the current active world if we’re switching maps. This is critical if you have UObjects that aren’t actors and you need to get the current world.

The only instance that stays in memory through a Map Load (initiated by OpenLevel) is the GameInstance. We need our own custom GameInstance class. To do this, go in the editor and click on the Tools menu and click “New C++ Class…”. A diolog comes up. Click on “All Classes”. In the search bar, type “GameInstance”. Click on “GameInstance”. Click Next. Give it a name and click “Create Class”.

Once you’ve built your project and loaded the editor, open up the project settings and select “Map & Modes”. The very last property is the game instance that your game will use. Change it to the one you just created.

The GameInstance’s GetWorld() will always return the current world. So we need a way to access it.

In the header, add the following in the public section replacing the class name with your own.

  UFUNCTION(BlueprintCallable)
  static MyGameInstance* GetGameInstance();

  UFUNCTION(BlueprintCallable)
  static UWorld* GetCurrentWorld();

This will let you get the GameInstance from anywhere in your codebase with:

MyGameInstance *GameInstance = MyGameInstance::GetGameInstance();

Or the current world:

UWorld *World = MyGameInstance::GetCurrentWorld();

These are usable from Blueprints as well.

In the .cpp file of your game instance, add the following. And yes, the implementation looks completely backwards. I still don’t understand why you need a world context to get the GameInstance when it is the only thing that survives a OpenLevel call.

MyGameInstance* MyGameInstance::GetGameInstance()
{
  FWorldContext* world = GEngine->GetWorldContextFromGameViewport(GEngine->GameViewport);
  MyGameInstance* GameInstance = Cast<MyGameInstance>(UGameplayStatics::GetGameInstance(world->World())); 
}

MyGameInstance* MyGameInstance::GetCurrentWorld()
{
  return GetGameInstance()->GetWorld();
}

We now have a way to get the GameInstance and the current world with ease from anywhere in our codebase. If you are executing code in an actor, you can always call this->GetWorld() directly on the actor. That will always be set. But if you’re in a UObject or a custom class, these methods will be useful.

Now when we load a level, we can get the proper world. This will work from anywhere.

UGameplayStatics::OpenLevelBySoftObjectPtr(MyGameInstance::GetCurrentWorld(), this->LevelToLoad);

Now we can look into knowing when the map has loaded. There is only one way of doing this that I have found.

And that is with these two virtual methods in the GameInstance class.

Add this to your header (public section):

  virtual void OnWorldChanged(UWorld* OldWorld, UWorld* NewWorld) override;
  virtual void LoadComplete(const float LoadTime, const FString& MapName) override;

When a Map loads, OnWorldChanged will be called, but BOTH worlds will be in memory at the same time. This is just to let you set up events if you wish. But the new world is not ready yet. The base class implementation updates its current world. This is how the GameInstance always has the most up to date world reference. It is very important to remember to call the base class implementation here.

Let’s add an empty implementation to start us off:

void MyGameInstance::OnWorldChanged(UWorld* OldWorld, UWorld* NewWorld)
{
  Super::OnWorldChanged(OldWorld, NewWorld);
}

void MyGameInstance::LoadComplete(const float LoadTime, const FString& MapName)
{
  Super::LoadComplete(LoadTime, MapName);
}

Shouldn’t we only need LoadComplete? That would tell us when the Map is loaded. Well, in theory yes. In practice, no.

When running in PIE (play in editor), LoadComplete is NOT called on the first level (the default one set in the project settings). Don’t ask why. It’s just a fact.

There’s also another little snag. In a standalone run, A fake “untitled” world is loaded, then unloaded and finally the default world is loaded. So we can’t just have a flag for the first world.

To get around all of this, we set up an event in OnWorldChanged. The irony is that we are using the level’s BeginPlay event. Since this is editor only, it should be fine.

void MyGameInstance::OnWorldChanged(UWorld* OldWorld, UWorld* NewWorld)
{
  Super::OnWorldChanged(OldWorld, NewWorld);

#if WITH_EDITOR
  // This can be made a property. Set it to the name of your default Map.
  const FString LevelName("NewPermanent");
  
  if (NewWorld != nullptr && NewWorld->WorldType == EWorldType::PIE && NewWorld->GetName() == LevelName)
  {
    NewWorld->GetOnBeginPlayEvent().AddLambda([this, &LevelName](bool flag)
    {
      if (flag)
        this->LoadComplete(0.0f, LevelName);
    });
  }
#endif
}

We’re not going to go into how to specify the default level name. There’s likely a way to read the project settings at runtime or grab it from a Soft pointer. There are many ways to go about this. Most people don’t change the default level, so hardcoding it here isn’t that bad.

At this point, we’re done the biggest part. When LoadComplete is called, the Map is done loading and can be used.

If you don’t care about level instances, you’re good to go and this is the end of the guide for you.
.
.
.

What if the default Map has a LevelInstance already in it?

In that case, when LoadComplete executes, the actors in the level instance may or may not be available. What does that mean? It means that level instances aren’t directly part of the Map and LoadComplete isn’t enough to know if the level instance is ready yet.

So how can we tell when a level instance is ready? Unfortunately, it’s not easy. It’s actually easier if you load it dynamically because you get a ULevelStreamingDynamic pointer and you can just bind to OnLevelShown. Note how that event has the word “Level” in it. That’s important in that these are all in separate levels.

For level instances already in the level, what do we do?

We need a new method that will actually be called when the level is fully loaded. Let’s add it now.

Add this to the header. And we’ll also need a timer handle and a method to handle the event. The timer handle can be private.

protected:
  virtual void LoadReallyComplete();

private:  
  void OnLevelTimer();
  bool AreLevelInstancesLoaded();

  FTimerHandle TimerHandle;

The implementation of LoadReallyComplete() is whatever you want to do when the Map is actually done loading.

But how do we get this to be called?

For in-map level instances, we need to query the Level Instance Subsystem.

So these methods in your cpp file should look like this:

void MyGameInstance::LoadComplete(const float LoadTime, const FString& MapName)
{
  Super::LoadComplete(LoadTime, MapName);

  // Set the delay in seconds for polling here.
  float PollDelay = 0.5f;

  if (!AreLevelInstancesLoaded())
    this->GetWorld()->GetTimerManager().SetTimer(this->TimerHandle, this, &MyGameInstance::OnTimer, PollDelay, true);
  else
    LoadReallyComplete();
}

void MyGameInstance::AreLevelInstancesLoaded()
{
  UWorld* world = this->GetWorld();
  if (world != nullptr)
  {
    ULevelInstanceSubsystem* LevelInstanceSubsystem = UWorld::GetSubsystem<ULevelInstanceSubsystem>(world);
    if (LevelInstanceSubsystem)
    {
	  // Get all level instances.
      TArray<AActor*> LevelInstanceActors;
      UGameplayStatics::GetAllActorsOfClass(world, ALevelInstance::StaticClass(), LevelInstanceActors);
      for (int i = 0; i < LevelInstanceActors.Num(); i++)
      {
        ALevelInstance* LevelActor = Cast<ALevelInstance>(LevelInstanceActors[i]);
        if (LevelActor == nullptr)
          continue; // Should never happen.

        // Check if the level instance is loaded.
        if (!LevelInstanceSubsystem->IsLoaded(LevelActor))
          return false;

        // Level Instance is loaded. We now have to check if Actors have been initialized.
        bool bIsLoaded = true;
        LevelInstanceSubsystem->ForEachActorInLevelInstance(LevelActor, [&bIsLoaded](AActor* Actor)
          {
            // Ignore World Settings.
            if (Actor->IsA<AWorldSettings>())
              return true;

            if (!Actor->HasActorBegunPlay())
            {
              bIsLoaded = false;
              return false;
            }

            return true;
          });

        if (!bIsLoaded)
          return false;

        // Finally, we have to check that the Level is actually initialized for the level instance.
        ULevel *Level = LevelInstanceSubsystem->GetLevelInstanceLevel(LevelActor);
        if (Level == nullptr)
          return false;
      }
    }
  }

  return true;
}

void MyGameInstance::OnTimer()
{
  if (!AreLevelInstancesLoaded())
    return;

  this->GetWorld()->GetTimerManager().ClearTimer(this->TimerHandle);

  LoadReallyComplete();
}

void MyGameInstance::LoadReallyComplete()
{
  // Add code here to execute when Map is fully loaded.
}

And that’s how you can have extremely reliable Level/Map loading.

What we’re doing is checking if the LevelInstance is fully loaded. If it is, then just continue on normally. If it isn’t loaded, then we create a timer event to check periodically. Once it’s loaded, we disable the timer and continue on.

The steps in checking if a level instance is loaded are:

  1. Check if the Level Instance is loaded
  2. Check if the actors have had BeginPlay called
  3. Check if the level instance Level has been created in the Map itself.

Next, we’ll add a callback so that we can be notified when a Map is done loading.

In your header file, add the following:

public:
  DECLARE_EVENT(MyGameInstance, FOnLoadCompletedEvent)
  FOnLoadCompletedEvent& OnLoadCompleted() { return LoadCompletedEvent; }

protected:
  FOnLoadCompletedEvent LoadCompletedEvent;

In your cpp file, add the following:

void MyGameInstance::LoadReallyComplete()
{
  LoadCompletedEvent.Broadcast();
  LoadCompletedEvent.Clear();
}

To use it, you just bind the event and call any of the OpenLevel functions.
Again, don’t use this in an actor.

Example:

FScriptDelegate D;
D.BindUFunction(this, FName("PostLoadLevel"));
MyGameInstance::GetGameInstance()->OnLoadCompleted().Add(D);
UGameplayStatics::OpenLevelBySoftObjectPtr(MyGameInstance::GetCurrentWorld(), this->LevelToLoad);

PostLoadLevel() will be called automatically when the level is done loading.

You can even use this from blueprints (NOT Actor blueprints and that BP must survive the Map load).

With this, you should have a VERY robust way of loading levels and continuing execution of setup code and gameplay after the Map has loaded.

Thoughts?

edit: When playing in the editor, AWorldSettings in the Level Instance doesn’t have BeginPlay called, so it must be ignored.

Also found out that in a packaged build, you have to set any level instances’ Level Behaviour to Standalone for level instances that are already in the map (ie, not loading dynamically). If you set it to Embedded, it won’t load at all in a packaged build. As in, it won’t be loaded and won’t appear at all.

Also, when running in the editor, MapName is empty on the first map when LoadComplete() is called.

I really think all of this should be cleaned up by Epic.