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:
- 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.
- I decide to test it, and start the Main Menu level and load the Tutorial level.
- 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
Vapor Trails
Heartfelt game about revenge (on Steam!)
Status | In development |
Author | sevencrane |
Genre | Platformer, Action |
Tags | Cyberpunk, Metroidvania, Pixel Art, speedrun-friendly, Story Rich, Unity, vaporwave |
Accessibility | Subtitles, Configurable controls |
More posts
- STEAM RELEASEJan 29, 2024
- 0.18.10 UpdateOct 12, 2022
- Ghost AI for AndromedaSep 26, 2022
- 0.17.31 UpdateApr 02, 2022
- 0.17.15 UpdateMar 25, 2022
- 0.16.5a PatchMar 15, 2022
- 0.16.4a PatchMar 14, 2022
- 0.16.3a UpdateMar 13, 2022
- 0.16.2a PatchOct 08, 2021
- 0.16.1a bugfixSep 18, 2021
Comments
Log in with itch.io to leave a comment.
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.
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! 😁
You can solve this problem quite easily with a "launch" scene.
Put all your DontDestroyOnLoad objects (and singletons) in that launch scene. To enter playmode in a specific scene, you would have a component (or editor script) that additively loads the launch scene content when entering playmode. Any timing issues need to be relaxed by invoking events (Action, Func) that interested components register with.
If a DontDestroyOnLoad object needs to reset its state, eg when changing scenes, you hook into SceneManager.sceneLoaded or similar events and perform your task. And then signal a "ready" event.
In your case you've also had dependent code in GlobalController's Awake. This has to be refactored. It's never a good idea to call other components from Awake, that needs to be deferred to Start or OnEnable. In Awake you should only ever get, find or create references but not accessing/calling them to avoid these initialization order issues.
Since you have a class called "GlobalController" you could as well rename it to "EverythingEverywhereAllAtOnce" and it'll still carry the same meaning. If you value tradition, suffix it with "Controller" or "Manager" or similarly broad, thus meaningless, nouns. ;)
Meaning: this class has no clearly defined purpose and thus it came to do all the things in Awake that it shouldn't have.