Why DontDestroyOnLoad is Bad


DO NOT FOLLOW THIS ADVICE

This is based on the incorrect assumption that ScriptableObjects are retained in memory between scenes. This is false, and has resulted in other bugs in VT.

Original Sin

Here's the simplest way to transfer runtime data (inventory, player stats, etc) between scenes: have a MonoBehaviour that stays with you when you load a new scene. This was one of the first things I wrote for Vapor Trails, and it's caused me a lot of grief ever since.

Here's one example of how:

  1. I'm making the Main Menu and Tutorial levels. Each one needs the player with the GlobalController to work, so each scene has a GlobalController.
  2. I decide to test it, and start the Main Menu level and load the Tutorial level.
  3. The GlobalController contains the player, camera, and everything else that's common between levels, as well as a bunch of static references. There's only ever supposed to be one, but there are suddenly two, and it fights with itself like two clones insisting they're the real one.

Band-Aid time

Here's how I "fixed" it at first.

I save a static variable that's common between classes. On load, it checks if it's the "real one," and destroys itself if it's not. This would be fine, if nothing else ran on Awake.

Band-Aid Bad

Unfortunately, I had a bunch of other scripts that had Awake functions that were nested with the GlobalController. Options menu setting, player HP setting, enemy AI initialization, camera initialization, and more all happen there. Changing the script execution order doesn't help, because Destroy waits for the end of the frame to actually remove the GameObject (which will be after all the other scripts have run Awake).

There's also DestroyImmediate, but Unity docs recommend not using it.

So for the last few releases, what I'd do is carefully go through every scene that wasn't the first one, and disable the GlobalController in that scene. Unfortunately, I'm only human, and a very absent-minded one at that, so I'd often miss one level or forget to do this entirely before building and releasing the build. It was very embarrassing, but I couldn't figure out a better way...until now.

Time for Scriptable Objects

People love talking about ScriptableObjects, because they're useful, but nobody ever explained why to my satisfaction when I was starting out. Here's one reason they're good:

ScriptableObjects keep their data changes at runtime and don't care about scenes.

They live outside scenes. They're like Prefabs in that MonoBehaviours can reference them, but they can't reference specific things inside a scene. Unlike Prefabs, changes to variables in ScriptableObjects persist as long as the game is running. They revert to their default state when the game exits, so they're not an easy way to save game data to the disk.

However, you can do this very easily.

I'm storing the runtime data in a ScriptableObject. You can change it in one scene, and it'll stay changed for when something in another scene references it.

Note that this still doesn't cover saving and loading the data. In my real game code, the save data is contained in the ScriptableObject as a normal class marked with [System.Serializable]. That way I can change its values in the editor and save and load it to disk with a binary formatter.

Refactoring all the other systems to use this took quite a while, but I thought it was about time it happened.

Further reading on architecture with ScriptableObjects

Color theme: Github Sharp Dark
Screenshots: CodeSnap

Get Vapor Trails

Download NowName your own price

Comments

Log in with itch.io to leave a comment.

(1 edit) (+1)

Isn't the point of DontDestroyOnLoad to carry an object over to the next scene? If that us the case, why do you need new instances of the object in all scenes? Won't it just carry the object over from the one scene over to all other scenes?

It makes playtesting easier and lets you separate out the player from the transitions, which is valuable once you have a complex class.

When I'm testing a level, I drop the Player prefab there, and that's fine. But if I compile the game with it there, and I load into that level from somewhere else in the game, there will suddenly be two Player objects. So I'd either have to make a bunch of logic for figuring out which Player is the duplicate one and deleting it before any of the Awake() hooks get called to register inventory and UI and whatnot, or go through every level (aside from the first one) and disable the Player object in it.

I also can't put it in the main menu, because if I stop mid-level and go back to the main menu, there will be two Players there, so I'd need a scene with only the Player object that will immediately load the main menu and take the Player with it.

There are other advantages, but that was the big one.

(1 edit) (+1)

Ah yes. I completely forgot about playtesting! Especially if you have many, many levels. Scriptable objects do make everything easier anyways. Thanks for the reply! 😁