Lives persist, score does not, can't figure this out

Hi there,

I’ve been struggling to debug this myself for like 8hrs and have kind of given up on it. Even asked chatGPT for multiple iterations of help, and still no luck, so I thought I’d ask here.

The strange behavior that I’m getting:

Lives are correctly persistent from level to level, both in the data and displayed in the UI. Looping around back to level 1 maintains lives too.
Score does not transfer from Scene 0 to Scene 1, and Scene 1 to Scene 2. If I loop around back to Scene 0, however, it does remember my score from that Scene 0 only. The UI updates on Scene 0 only, and doesn’t do so in later scenes.
When I die and reload a level, it correctly remembers which coins have already been collected.

I copied my code below.

I am using prefabs for both the GameSession and LevelPersist objects.

My debug printouts make it seem like the GameSession is indeed being destroyed on load for each level. Yet when a coin is captured, it manages to call a gameSession.captureCoin() on some other copy of gameSession, because I can still query gameSession.getCurrentScore() and output a restarted-score rather than the one that should be saved in the DontDestroyOnLoad version of gameSession.

Any help greatly appreciated! It’s so frustrating because I feel like I understand the principles but am getting really confusing behavior.

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using TMPro;
public class GameSession : MonoBehaviour
{
    [SerializeField] int playerLives = 3;
    [SerializeField] int playerScore = 0;
    [SerializeField] float deathDelay;
    [SerializeField] TextMeshProUGUI livesText;
    [SerializeField] TextMeshProUGUI scoreText;

    int currentLevelIndex;

    void Awake(){
        // singleton creation below
        int numGameSessions = FindObjectsOfType<GameSession>().Length; // Find ObjectS (plural) Of Type, bc could have several of these
        Debug.Log("Awake for GameSession is called. Active Scene: " + SceneManager.GetActiveScene().buildIndex);
        if (numGameSessions > 1){
            Destroy(gameObject);
            Debug.Log("GameSession was a copy, destroyed self");
        }
        else{
            DontDestroyOnLoad(gameObject); // this will keep original copy of singleton, persisten when future levels load with a GameSession object
        }
    }

    private void Start(){

        scoreText.text = playerScore.ToString();

        livesText.text = playerLives.ToString();

    }

    public void ProcessPlayerDeath(){

        currentLevelIndex = SceneManager.GetActiveScene().buildIndex;

        if (playerLives > 1){

            TakeLife();

            Debug.Log("Died. Reloading level. " + getPlayerStats());

            Invoke("ReloadScene", deathDelay);

        }

        else{

            TakeLife(); // for the screen text

            Debug.Log("No more lives. Resetting game session");

            Invoke("ResetGameSession", deathDelay);

        }

    }

    private void TakeLife() {

        playerLives --;

        livesText.text = playerLives.ToString(); // do I need this, or is it constantly pulling from the variable? I don't think so

    }

    private void ResetGameSession()

    {

        FindObjectOfType<LevelPersist>().ResetLevelPersist(); // destroy that version of LevelPersist, we're moving on!

        SceneManager.LoadScene(0); // start over

        Destroy(gameObject); // destroy this instance of GameSession, get a whole new instance of main session

    }

    private void ReloadScene(){

        SceneManager.LoadScene(currentLevelIndex);

    }

    public void captureCoin(int value){

        playerScore += value; // doing it like this, because might be other ways to increase your score. Score != coins.

        Debug.Log("GameSession msg: coin captured, current score: " + playerScore);

        scoreText.text = playerScore.ToString();

    }

    public int getCurrentScore(){

        return playerScore;

    }

    public int getCurrentLives(){

        return playerLives;

    }

    public string getPlayerStats(){

        return "Lives remaining: " + getCurrentLives().ToString() + ", score: " + getCurrentScore().ToString();

    }

}
using System.Collections;

using System.Collections.Generic;

using UnityEngine;

using UnityEngine.SceneManagement;

public class LevelPersist : MonoBehaviour

{

   void Awake(){

        Debug.Log("Awake for LevelPersist is called. Active Scene: " + SceneManager.GetActiveScene().buildIndex);

        int numLevelPersist = FindObjectsOfType<LevelPersist>().Length;

       

        if(numLevelPersist > 1){

            Destroy(gameObject);

            Debug.Log("Level Persist sees it is a copy, destroys itself");

        }

        else{

            DontDestroyOnLoad(gameObject);

        }

    }

    public void ResetLevelPersist(){

        Destroy(gameObject);

        Debug.Log("Deleting LevelPersist");

    }

}
using System.Collections;

using System.Collections.Generic;

using UnityEngine;

using UnityEngine.SceneManagement;

public class LevelExit : MonoBehaviour

{

   

    [SerializeField] ParticleSystem finishEffect;

    bool hasFinished;

    int currentLevelIndex;

    GameSession gameSession;

    void Start(){

        hasFinished = false;

        gameSession = FindObjectOfType<GameSession>();

        currentLevelIndex = SceneManager.GetActiveScene().buildIndex;

    }

    void OnTriggerEnter2D(Collider2D other) {

        if(hasFinished == true){

            return;

        }

        if(other.tag == "Player") { // only hits if hasFinished == false, obviously

            hasFinished = true; // so you can't hit the exit twice

            Debug.Log("End of level. " + gameSession.getPlayerStats());

            StartCoroutine(exitLevel(1f));

            finishEffect.Play(); // particle system

        }

    }

   

    public IEnumerator exitLevel(float time){

        yield return new WaitForSecondsRealtime(time);

        // it will wait before executing the following

        FindObjectOfType<LevelPersist>().ResetLevelPersist(); // destroy that version of LevelPersist, we're moving on!

       

        // yield return null; // to make sure it's getting the full destroy in

       

        if(currentLevelIndex == SceneManager.sceneCountInBuildSettings-1){

            // you finished last level, so loop back to first level

            // Debug.Log("End of level. " + gameSession.getPlayerStats());

            SceneManager.LoadScene(0);

        }

        else{

            // Debug.Log("End of level. " + gameSession.getPlayerStats());

            SceneManager.LoadScene(currentLevelIndex+1);

        }

    }

}
using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class Coin : MonoBehaviour

{

    GameSession gameSession;

    SpriteRenderer coinRenderer;

    // [SerializeField] AudioClip coinSFX;

    [SerializeField] ParticleSystem coinParticle;

    [SerializeField] int coinValue = 10; // default

    CircleCollider2D coinCollider;

    AudioSource audiosource;

    bool isCaptured;

    void Start(){

        gameSession = FindObjectOfType<GameSession>();

        coinRenderer = GetComponent<SpriteRenderer>();

        coinCollider = GetComponent<CircleCollider2D>();

        isCaptured = false;

        audiosource = GetComponent<AudioSource>();

    }

    private void OnTriggerEnter2D(Collider2D other) {

        if(other.tag == "Player" && !isCaptured){

            gameSession.captureCoin(coinValue);

            Debug.Log("Coin msg: coin captured, current score: " + gameSession.getCurrentScore().ToString());

            // Debug.Log("Current coins: " + gameSession.currentCoins());

            coinParticle.Play();

            // AudioSource.PlayClipAtPoint(coinSFX, Camera.main.transform.position, 0.5f);

            audiosource.Play();

            isCaptured = true;

            // gameObject.SetActive(false); // can do this instead of disabling the collider etc

            coinRenderer.enabled = false;

            coinCollider.enabled = false;

            RemoveCoin();

        }

    }

    void RemoveCoin(){

        if(!isCaptured){return;} // not captured = nothing to do

        else if(coinParticle.IsAlive()){return;} // it is captured, but still  emitting, so keep Updating

        else{Destroy(gameObject,1.5f);} // animation is done, destroy the object. Add extra 1.5s for the audio to finish

    }

}

Where does the UI live? Is it in the game session prefab?

In the Heirarchy, Canvas is a child of Game Session, and TextMeshPros are both children of Canvas.

Can you check your levels and make sure that any gamesessions that may be in them don’t have any changed values in the scene? In other words, apply any changes they may have in the scene

In the heirarchy: the GameSession that gets put under DontDestroyOnLoad does not have its values changed.

But from my debug code, this other phantom GameSession does keep track of points scored on that level only. If I die on the level, it still remembers this level score. When I debug print the score and lives to the console at ExitLevel, it give me however many points I scored since last death, and 3 lives remaining no matter what.

The GUI and game restart trigger correctly track the corret lives count.

Try this: Run the game and check the hierarchy. You should see 2 scenes; the level you’re on, and the DDoL scene.
Type ‘gamesession’ in the search box
image
Check if you have more than one on a scene. Do this for every level - or until you find one that shouldn’t be there

Every level only has one. I retyped it every level, I looped three times, and there is only ever one GameSession in the heirarchy. So which version of playerScore is this thing updating…? Very weird.

I’ll keep looking through it.

Were they all in the DDoL scene?

Okay, added this debug line to my Coin script, in its start method:

Debug.Log("Coin sees this many numGameSessions: " + numGameSessions.ToString());

And sure enough, when I go to level 2, and the coins Start hits, they each see 2 GameSessions!

From what I read online, objects’ awake methods get called in the order that they are listed in the heirarchy. So I made sure my GameSession is higher than the LevelPersist class (LevelPersist contains the coins), so that the GameSession realizes it’s a copy and deletes itself before the coins are instantiated.

This didn’t work still. Ugh.

So I went to the coin’s OnTriggerEnter2D method, added these lines:
gameSession = FindObjectOfType();

    numGameSessions = FindObjectsOfType<GameSession>().Length;

    Debug.Log("Coin sees this many numGameSessions: " + numGameSessions.ToString());

This fixed it!

This can’t be the intended behavior of the Unity enigine, no? Surely I should be able to trust that the Coins don’t instantiate and grab onto soon-to-be-destroyed copy of the GameSession, before the GameSession’s Awake method is called!

Question to the Teaching Assistants: what should I take away from this? What best practice should I be using? Don’t always trust Start methods when it comes to connecting together persistent objects?

No, Unity sees the objects in some order. No guarantee what that order is. When you do FindObjectOfType<GameSession>() before the GameSession's awake has determined that there are too many and killed one, there is a chance that you could get hold of the one that is about to get destroyed. That’s why we don’t usually get singletons using FindObjectOfType but expose an Instance. Then we can be sure that we are getting the instance we want

Thanks bixarrio. Could you give a brief explanation of the right way to do that, ie expose an Instance?

I think its the case that @Brian_Trotter described to me a couple of days ago in this topic:

The pattern for singletons I usually use looks like this

public void SomeComponent : MonoBehaviour
{
    // the instance that we are keeping in DDoL
    public static SomeComponent Instance ( get; private set; }

    private void Awake()
    {
        // If we already have an instance, and that instance is not this instance
        if (Instance != null && Instance != this)
        {
            // Kill this instance. We don't want it
            Destroy(gameObject);
            // Nothing more to do here
            return;
        }
        // The instance is null (or it is this instance already). Set it to this instance
        Instance = this;
        // Add this instance to DDoL
        DontDestroyOnLoad(gameObject);
    }

    public void SayHello()
    {
        Debug.Log("Hello");
    }
}

Now, whenever I want access to SomeComponent I can just use

SomeComponent.Instance.SayHello();

Note, I did say we can get the instance we want (instead of another that has not been destroyed yet), but this does not mean it will magically exist. If you are trying to access Instance before it is created, it will be null. Check @Medial’s linked post for some more details

Thank you for this.

After reading @Medial response to @Brian_Trotter, sounds like even the more elegant creation of a singleton isn’t going to avoid the mistake I made in using FindObjectOfType() in the Start() method of an outside class. I’d have to wait until another method in that outside class to reference the singleton in confidence (either an Update() or OnColissionEnter2D() or something else that gets called after Start()).

Appreciate you both!

Usually best to start here. ChatGPT is good at generalized answers, not so great at matching up with your pre-existing code

Let’s rework this class, to avoid our not-yet destroyed bug:

public GameSession Instance;

void Awake()
{
     if(Instance!=null  & Instance!=this) //Shh, don't tell anybody I fixed a typo on this line
     {
          Destroy(gameObject); // or this one
          return; 
      }
      Instance=this;
      DontDestroyOnLoad(gameObject);
}

It’s actually that simple to make a Singleton. I trust in this case, you’ve put this script on a Canvas and have the livesText and scoretext as child objects to the Canvas (this will break otherwise).

Now in Start(), you should be able to refer to the instance, but in tis case, I don’t believe you HAVE to. In Coin, for example, you don’t need a GameSession gameSession at all. This means that you can simply refer to

GameSession.Instance.captureCoin(coinValue);

within OnTriggerEnter2D

Another solution for the “not-yet destroyed bug” is to disable the GameSession object which is about to get destroyed:

if(numLevelPersist > 1){

    gameObject.SetActive(false);
    Destroy(gameObject);

    Debug.Log("Level Persist sees it is a copy, destroys itself");
}

FindObjectOfType finds active objects only.

should the line

Destroy(gameObject & instance!=this)

have instance upercased Instance instead?

Do we have to make it a static, like bixarro did in his example? What are the benefits / drawbacks of also giving it the get; private set; stuff?

Thanks again!

You caught me, Not only should it be Instance instead of instance, but the &Instance!=this goes in the if statement, not the destroy statement. This is what happens when TA’s answer questions after they take their sleeping pills. :stuck_out_tongue:

Privacy & Terms