Day 21: Scene Management

Today we took a look at the Scene Manager, which allows us to make the jump out of our scene and into new ones. This introduces some challenges when we want to retain information or objects between these scenes, and so we look at how we can use the singleton pattern to help us manage this process.

Part 1 – Scenes

In the early days of gaming, developers would divide a game up into small chunks called “levels”.   While they were useful for players to measure their progress, their real purpose was to minimize the amount of data a game had to hold in memory in order to be playable.   Even today, as games allow us to explore cities, countries, worlds, galaxies… the “level” is commonly used as a way to divide up content.  It would be silly to hold the final boss battle of a game in active memory when a player is hours away from encountering it.   Each draw call would have to consider every polygon in the entire game, every object and behavior would have to be set just so.  In short, it would be chaos.  Computationally expensive chaos.  Why spend all of those cycles considering things that are hours away from use?

So instead we break up our game into smaller chunks and these load into memory when they are needed, hence the “loading” screen that so many games have.  (You may be thinking “but what about sandbox games?”  Well, those are divided into smaller areas as well – and not just areas, even objects inside those areas have various levels of detail that can be called up.  The loading of that content happens in the background, and will swap places with a lower quality model when ready, usually fading between the two so as to disguise the effect and not distract the eye.  Most often this happens in the distance, and the game is using predictive algorithms to determine which content you are most likely to need yet and start to load it.)

When Unity was first created, they also included a level system, which became known as Scenes.  You will recognize this today as the format that we save our files in.  Scenes have evolved to be much much more than just game levels, and are frequently seen used for other cases such as UI screens/menus, code and asset loading, and animated cutscenes.  Sometimes entire games will be saved as scenes.   Projects (the things we open when we launch Unity, and where our Assets and such are stored) can contain multiple scenes, and when we build our game we will designate which of the scenes we create will be included in the build.  (This is particularly useful when you’re working on branches or variations of your game – you can simply swap one out for another if you need to!)

For our in-class demo, we changed the name of our main game scene from the Unity default “SampleScene” to “Level01”. We also created a new scene (“StartMenu”) to house our UI, and a quick small game scene we named “Tutorial”.

NOTE – Don’t worry too much today about the buttons, we will take a deeper dive into the UI system in the next class.

Part 2 – Setting up Buttons

Unity’s UI buttons are easy to generate by going to Create > UI > Button or in the top menu GameObject > UI > Button.  This will create a new button object as a child of the Canvas object (which will also be created if you don’t already have one).  Buttons consist of two parts, the Button object, and a child Text object.   The Button object has two Components to note – the “Image (Script)” component where you define the look of your button, and the “Button (Script)” component where you can define the behaviors and transitions of the buttons.  The default button transition is a color tint, but If you have pre-made images for your button states, you can use the “Sprite Swap” transition to show them.  The child text object is optional.

Now for the complicated part – adding an action to your button.  Buttons have an “OnClick()” message that they send when a user clicks them but accessing them is not as simple as accessing collider messages.  At the bottom of the “Button (Script)” component you will see an “OnClick()” panel that is empty.  If you click the “+” button it will create a new action.  This action requires an object to be associated with it in order to access its scripted functions.   You can add a script to the Button object, but then you will have to self-associate the button with itself.   NOTE: The object HAS to be an object currently in the hierarchy.  Linking to a prefab does not work.

In class, I created an empty game object (“LevelManager”) and added a script (also called “LevelManager”) which contained a single function that we would use to move to the next scene (“btn_StartTheGame”). In the OnClick( ) menu I associate the script object and select a function from the dropdown on the right by going to the specific component “LevelManager” and selecting the function I want to use.

NOTE:  Any function that will be accessed by a Button must be PUBLIC, and VOID.  It can only take up to one (1) parameter, that parameter can only be a Float, Int, String, or Object.

Part 3 – Scene Manager

Now that we have a button in place, let’s connect some scenes.  I’m going to make our LevelManager script handle our first scene jump.

First, I have to add a new library to the code, so I start the file by adding this line:

using UnityEngine.SceneManagement;

Now we can access the scene management commands.   I can create a button command, so looking at our button example above to make us jump to the Level01 scene, I create the following in button script.

public void btn_StartTheGame()
{
    SceneManager.LoadScene("TutorialLevel");
}

Again, please note that this must public and void for the editor to see it.  Here, ours takes no arguments.    And the command itself is rather simple – we run the LoadScene( ) function and pass in the string name of our level.   We can also pass in an integer that corresponds with the scene index.  Speaking of…

Part 4 – Adding Scenes to the Build

In order to load scenes, Unity has to have those scenes associated with the build.  You can do this by going to File > Build Profile > Scene List, and dragging the scenes you will be using from the Asset panel into the scene list in Build Settings.  Notice that when you add a scene, it also assigns it an indexed location.

Once you have associated the scenes, you can close the window.  (There is no save button to worry about, your changes are automatically registered.)

Part 5 – Staying Alive (DontDestroyMeBro!)

If we are making a game, chances are we want to do things like keep track of the score, or the number of lives remaining.  A GameManager script seems like the best candidate for this. We created an empty object, assigned it a GameManager script which sets up a singleton, and converted it into a prefab. We made sure to include this prefab in both of our platform game scenes, as we will need it to handle our logic for events like player deaths, inventory, and of course, the score.

We edited our Level Manager to also be a singleton (so that we can automatically access it within a scene) and added a public string to hold the name of the next scene we should visit upon successful completion of the level.

If our player successfully reaches the end of a level, we reward them by sending them to the next one. To support this, we set a trigger at the end of the level, and tagged it with a “EndOfLevel” tag. If the Player object enters the trigger with that tag, it calls the levelEvent_END_OF_LEVEL( ) command from the LevelManager and that sends the player to the appropriate scene.

    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.transform.tag == "EndOfLevel")
        {
            LevelManager.S.levelEvent_END_OF_LEVEL();
        }
    }

In our LevelManager script, we define our scene jump like so:

    public void levelEvent_END_OF_LEVEL()
    {
        SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex + 1);
    }

Now we are changing scenes, but we have a problem. Our GameManager only exists in the scene that I created it in.  As soon as I jump to a new scene, it gets destroyed just like everything else in its host scene and THAT scene’s GameManager instance appears. Our score, lives remaining, gamestate, all of the important values that GameManager was probably hanging on to are gone in an instant.

If I want to hold onto these things, I need to make GameManager persist across the transition from one scene to the next.

To accomplish this, we spare GameManager from an untimely deletion by adding the following line to the Start ( ) function in the GameManager script.

DontDestroyOnLoad (this);

DontDestroyOnLoad ( ) preserves a game object across transitions.  It actually creates a little side scene in the Hierarchy and moves the object into it during runtime, as seen below:

Once we hit play, our GameManager object moves to a side scene

Now when we jump scenes, this object remains, and our score persists as we play through.

But… (you knew there would be one, didn’t you?)

For our game to run correctly, we need to have GameManager running in every scene. This means that either we would ALWAYS have to start our game from the very beginning in the scene where we first instantiate it and play all the way through – not fun, especially when we just want to test one tiny thing in level 5 – OR we have to find a way to include it in EVERY scene. This is why we chose to make our Game Manager a prefab, and drop an instance of it into every level.

But… if we are not destroying our Game Manager and we load a scene with another instance of Game Manager, won’t there be two Game Managers?

Yes there will, and that is a problem.  Because now not only do we have two game managers, the new one has just declared itself the singleton (S = this) and overwritten the old one, meaning the new object’s score is the one we display and that’s not what we want at all, is it?   And if we move scenes again, since both of these now live within the DontDestroyOnLoad section, we will be adding a third Game Manager and overwritting our Singleton yet again and this seems bad.

So to avoid this situation, we create a small workaround.  We build in a check in our Game Manager start script that checks to see if any instance of the singleton exists.  If none exists, great – we’re first to the party and we set up shop.  But if there is already a singleton defined, we kill this instance of the manager as it is not needed.  The script for that looks like this:

private void Awake()
{
    if (S)
    {
        Destroy(this.gameObject);  
        // Singleton already exists, remove this instance
    } else { 
        S = this;  // Singleton Definition
    }
}

Here we check for the existence of another version of GameManager.  If none exists, GameManager.S will return “null”.  If not null, someone is already here.  We destroy this new version, and we submit a “return” command, which exits our script rather than continuing on.

Once we’ve passed our null-check, the script is still executing so we know we must be the one true game manager. We assign Singleton status and then in the Start( ) method we declare our intention to live forever.

Part 6 – I’m Ready to Go Now

What about when we DO want something to disappear? In the case of our game flow, how do we reset a game if we reach a win or lose state? How to do we go from zero lives remaining back to three, to full health, to an empty inventory? We could write an elaborate script to reset our conditions internally, but the easier method would be to simply let the GameManager die, return to the Title Screen, and let the next GameManager that comes along start the process all over again.

It turns out that the answer to this is quite simple – we Destroy( ) the Game Manager from within itself. DontDestroyOnLoad only preserves objects across transitions – it does not make them immortal.

In this example, we embed a script in our StartMenu scene that will destroy any GameManagers that may be lingering around.

void Start()
    {
        if (GameManager.S) { Destroy(GameManager.S.gameObject); }
    }

Part 7 – Restarting a Level

My favorite method for managing death and respawning in a platform game is to simply restart the level by reloading it. This means that all objects in the scene will be destroyed and re-instantiated.

public void levelEvent_RELOAD_LEVEL()
{
     SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
}

Here, we tell the scene manager to load the scene that we are already currently in by passing in the buildIndex value of the Scene type returned from GetActiveScene( ). We could also pass in the name property of the Scene since LoadScene can take either int or string arguments.

If you DO need to preserve states or items , you can store this information in your undestroyed game manager and when the level restarts, have the objects check for relevant data. For instance, if you were building checkpoints into your game you could store a “checkpoint” value with a unique ID for where to place the player.

Better yet, you could make a Level Manager which would store the states of the objects in the level itself, but which would destroy itself when your script moved on to the next level. Then you have a GameManager that lives across the game, and a LevelManager that lives across attempts at a level.

Part 8: What about UI?

One common issue that occurs when we jump between scenes is that any values/variables associated with scene objects are probably going to break, because that scene no longer exists. No scene, no UI text object showing our score! Sure, a new scene appears, we may even be reloading the same scene, but from the perspective of the engine it is a new object and thus NOT the one we assocated.

There are multiple approaches to address this. One method would be to attempt to find a new instance of that object if the previous one is found to be null or missing. Another is to also set those objects to designate as Don’tDestroyOnLoad, and then perform checks in each scene for duplicates.

Perhaps the easiest method to keep your UI alive across scenes is to simply make your Canvas and Event System a child of our GameManager. By doing this, they inherit the properties of the GameManager, and when that Game Object is moved to the DontDestroyOnLoad scene, the child objects come along with it.

This method also works very well because as a child of the GameManager’s object, it is also affected by the Destroy command that we use in our singleton’s self-destruct option. No duplicate GameManager means no duplicate Canvas children either!



GameManager.cs
using UnityEngine;
using TMPro;
using UnityEngine.SocialPlatforms.Impl;

public class GameManager : MonoBehaviour
{
    public static GameManager S;
    public TextMeshProUGUI scoreText;
    private int score = 0;


    private void Awake()
    {
        if (S) { Destroy(this.gameObject); } else { S = this; } // singleton definition
    }
    private void Start()
    {
        // designate as dont destroy
        DontDestroyOnLoad(this.gameObject);

        // set the initial score
        UpdateScoreUI();
    }

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.C))
        {
            // debug test for scoring mechanism
            IncreaseScore(50);
        }
    }

    public void IncreaseScore(int scoreAmount)
    {
        score += scoreAmount;
        UpdateScoreUI();
    }

    private void UpdateScoreUI()
    {
        scoreText.text = "Score: " + score;
    }

}


LevelManager.cs
using UnityEngine;
using UnityEngine.SceneManagement;


public class LevelManager : MonoBehaviour
{
    public static LevelManager S;

    private void Awake()
    {
        if (S) { Destroy(this.gameObject); } else { S = this; } // singleton definition
    }

    public void levelEvent_END_OF_LEVEL()
    {
        SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex + 1);
    }

    public void levelEvent_RELOAD_LEVEL()
    {
        SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
    }

    public void levelEvent_RETURN_TO_MENU()
    {
        SceneManager.LoadScene("StartMenu");
    }
    public void btn_StartTheGame()
    {
        // when pressed, go to the first level
        SceneManager.LoadScene("Tutorial");
    }

}


PlayerScript.cs
using UnityEngine;

public class PlayerScript : MonoBehaviour
{
    // Start is called once before the first execution of Update after the MonoBehaviour is created

    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.transform.tag == "EndOfLevel")
        {
            LevelManager.S.levelEvent_END_OF_LEVEL();
        } else if (collision.transform.tag == "EndOfGame")
        {
            LevelManager.S.levelEvent_RETURN_TO_MENU();
        }
    }

    private void OnCollisionEnter2D(Collision2D collision)
    {
        if (collision.transform.tag == "Enemy")
        {
            LevelManager.S.levelEvent_RELOAD_LEVEL();
        }
    }

}