This will also be covered in detail in the Inventory course we are working on right now.
I think it was kinda hard since there was a lot to keep in mind, especially with the indentifiers and the saving multiple scenes part.
But I think you did a pretty good job with this tutorial. The challenges are escpecially great since I could easily learn from my mistakes. I feel like I have a good understanding with how a save system works. Thanks Sam!
Hello!
Short disclaimer first; I did not implement the save system but mostly listened through this section and kept up with it. This kind of programming is more of what Iām used to than game-logic, so I found this intuitive and well explained!
I did notice a strange ābugā with the save system in Unity 2020.3, that has to do with the call to LoadSceneAsync
. This ābugā was noticed when I changed the Load
function in my SavingWrapper class to reload the scene again, done by calling LoadLastScene from the SavingSystem class.
The problem I encountered was that enemies that had been killed in an earlier save would suddenly come back to life if I saved and then loaded the game again. This seems to come from a short delay between LoadSceneAsync
and when the function Start
is called. The result of this seems to be that the call-order is something akin to:
LoadSceneAsync
RestoreState
Start
So while all entities with the component SaveableEntity will be correctly updated during the call to LoadLastScene
, this information will quickly be overwritten by the Start
function inside each MonoBehaviour
. To fix this, I added a yield return new WaitForEndOfFrame()
to the LoadLastScene
function inside of SavingSystem, between the call to LoadSceneAsync
and RestoreState
. This seems to fix the issue at hand:
public IEnumerator LoadLastScene(string saveFile)
{
Dictionary<string, object> state = LoadFile(saveFile);
int buildIndex = SceneManager.GetActiveScene().buildIndex;
if (state.ContainsKey("lastSceneBuildIndex"))
{
buildIndex = (int)state["lastSceneBuildIndex"];
}
yield return SceneManager.LoadSceneAsync(buildIndex);
yield return new WaitForEndOfFrame();
RestoreState(state);
}
Worth noting! This problem could stem from the fact that I use the new InputSystem with events to call my Load
function inside the SaveWrapper
class. I am uncertain if this bug will show up when LoadLastScene
is called from inside the Update
function.
Ah, the issue here is that certain things are being done in Start() that really should be done in Awake()ā¦
Some things are unsafe to set in Awake, however, and thatās why weāve introduced LazyValues.
The order of events when Loading a scene (or portalling to a new scene are as follows:
- LoadSceneASync()
- Awake() (You can safely cache references to other components here, but donāt depend on anything in those components quite yet
- RestoreState()
- OnEnable() - You can safely subscribe to events here
- Start() - You can safely start accessing other classes, the issue being that if RestoreState has fired, then you might overwrite the data from RestoreState, hence the LazyValues (or uninit traps like starting with health=-1; as the declaration, and then in Start() {if health<0) health = ā¦}
Just wanted to say Iām really enjoying this course, Iāve learned a lot and I really appreciate your approach to the design of the code. Taking this saving section (in addition to just learning more about saving in general) really helped me with learning how to better section off different types of tasks in a way thatās very clear to follow and avoids dependencies and spaghetti code (which I definitely used to have a problem with when I was first learning C#). Thank you so much for all of this information! Iām excited to keep learning!
This course is awesome. The clarity and the way sam present things is making it so fluid ! He is by far one of the best instructor i know regarding unity code programming
Iāve just completed the second saving section that goes through the full creation but the lazyvalue was never introduced in that section, but I remember using it years ago when I did the course for the first time when completing the first saving section. Is the use of the lazyvalues important?
I already saved all of my caches in awake the only thing I havenāt got in the order youāve suggested is I still have LoadSceneASync being called in start.
I also recommend that everyone check out my message about the security risks with using Binary Formatter:
Iām super happy with it!
I added the ability to Save GameObjects that are not active, can change this in the inspector.
I also added Version Control to my saving system with backwards compatibility.
Here is the code that was added/changed to get these features
VersionControl.cs
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
using UnityEngine;
using UnityEngine.SceneManagement;
using static RPG.Saving.VersionControl;
namespace RPG.Saving
{
public static class VersionControl
{
public static int currentFileVersion = 1;
public static int minFileVersion = 1;
}
}
SavingSystem.cs
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
using UnityEngine;
using UnityEngine.SceneManagement;
using static RPG.Saving.VersionControl;
namespace RPG.Saving
{
public class SavingSystem : MonoBehaviour
{
#region Inspector Fields
[Header("Config Data")]
[Tooltip("Toggle this to true if you want the saving system to save state for inactive objects.")]
[SerializeField]
private bool includeInactive;
#endregion
#region Private Methods
private Dictionary<string, object> LoadFile(string saveFile)
{
string path = GetPath(saveFile);
if (!File.Exists(path)) return new Dictionary<string, object>();
BinaryFormatter formatter = new BinaryFormatter();
using FileStream stream = File.Open(path!, FileMode.Open);
// Version Control
Dictionary<string, object> state = (Dictionary<string, object>)formatter.Deserialize(stream);
int currentFileVersion = 0;
if (state.ContainsKey("CurrentFileVersion"))
{
currentFileVersion = (int)state["CurrentFileVersion"];
}
if (currentFileVersion >= VersionControl.currentFileVersion || currentFileVersion >= minFileVersion)
return state;
Debug.LogWarning($"Save file is from an older version of the game and is not supported. " +
$"Expected version: {VersionControl.currentFileVersion}, " +
$"Minimum Expected version: {minFileVersion}, " +
$"Current version: {currentFileVersion}");
return new Dictionary<string, object>();
}
private void CaptureState(Dictionary<string, object> state)
{
// Version Control
state["CurrentFileVersion"] = currentFileVersion;
// Ability to include inactive game objects
foreach (SavableEntity entity in FindObjectsOfType<SavableEntity>(includeInactive))
{
state[entity.GetUniqueIdentifier()!] = entity.CaptureState();
}
state["lastSceneBuildIndex"] = SceneManager.GetActiveScene().buildIndex;
}
private void RestoreState(Dictionary<string, object> state)
{
// Version Control
int currentFileVersion = 0;
if (state.ContainsKey("CurrentFileVersion"))
{
currentFileVersion = (int)state["CurrentFileVersion"];
}
// Ability to include inactive game objects
foreach (SavableEntity entity in FindObjectsOfType<SavableEntity>(includeInactive))
{
string id = entity.GetUniqueIdentifier();
if (!string.IsNullOrWhiteSpace(id) && state.ContainsKey(id))
{
// Version Control
entity.RestoreState(state[id], currentFileVersion);
}
}
}
#endregion
}
}
SavableEntity.cs
namespace RPG.Saving
{
public class SavableEntity : MonoBehaviour
{
#region Public Methods
// Version Control
public void RestoreState(object state, int currentFileVersion)
{
foreach (ISavable savable in GetComponents<ISavable>())
{
Dictionary<string, object> stateDict = (Dictionary<string, object>)state;
string typeString = savable.GetType().ToString()!;
if (stateDict.ContainsKey(typeString))
{
// Version Control
savable.RestoreState(stateDict[typeString], currentFileVersion);
}
}
}
#endregion
}
}
ISavable.cs
namespace RPG.Saving
{
public interface ISavable
{
// Version Control
void RestoreState(object state, int version);
}
}
Anything that implements ISavable use RestoreState(object state, int version)
instead of RestoreState(object state)
For the Editor Window in an Editor Folder
SaveEditorWindow.cs
using System.IO;
using UnityEditor;
using UnityEngine;
using static UnityEngine.Screen;
namespace RPG.Saving.Editor
{
/// <summary>
/// A custom editor window
/// <seealso href="https://docs.unity3d.com/ScriptReference/EditorWindow.html"/>
/// </summary>
public class SaveEditorWindow : EditorWindow
{
private const float WindowWidth = 400;
private const float WindowHeight = 200;
private const string HelpBoxText =
"Every Save File has a Version Number. When trying to load a save, only files with the current version (or the minimum legacy version) will be valid.";
private int m_cachedVersionNumber;
private int m_cachedMinVersionNumber;
private bool m_legacySupport;
private GUIStyle m_centeredLabel;
#region Window Managment
[MenuItem("Window/RPG Tool Kit/Save Settings")]
private static void ShowWindow()
{
SaveEditorWindow window = GetWindow<SaveEditorWindow>("Save Settings", true);
//Set default size & position
Rect windowRect = new Rect()
{
size = new Vector2(WindowWidth, WindowHeight),
x = (float)currentResolution.width / 2 - WindowWidth,
y = (float)currentResolution.height / 2 - WindowHeight
};
window.position = windowRect;
window.m_legacySupport = VersionControl.minFileVersion != VersionControl.currentFileVersion;
window.m_cachedVersionNumber = VersionControl.currentFileVersion;
window.m_cachedMinVersionNumber = VersionControl.minFileVersion;
window.m_centeredLabel = EditorStyles.boldLabel;
window.m_centeredLabel.alignment = TextAnchor.MiddleCenter;
window.Show();
}
/// <summary>
/// <seealso href="https://docs.unity3d.com/ScriptReference/EditorWindow.OnGUI.html"/>
/// </summary>
private void OnGUI()
{
// Help Box
EditorGUILayout.HelpBox(HelpBoxText, MessageType.Info);
EditorGUILayout.Space();
//Version Number Editing
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("Current Version NR.", GUILayout.MaxWidth(150));
if (m_cachedVersionNumber == 1) GUI.enabled = false;
if (GUILayout.Button("-", GUILayout.MaxWidth(25))) ShiftCurrentVersion(-1);
if (!GUI.enabled) GUI.enabled = true;
EditorGUILayout.LabelField($"{m_cachedVersionNumber}", m_centeredLabel, GUILayout.MaxWidth(35));
if (GUILayout.Button("+", GUILayout.MaxWidth(25))) ShiftCurrentVersion(1);
EditorGUILayout.EndHorizontal();
// Legacy Version Number Editing
m_legacySupport = EditorGUILayout.Toggle("Backwards Compatibility", m_legacySupport);
if (m_legacySupport)
{
if (m_cachedMinVersionNumber > m_cachedVersionNumber)
{
m_cachedMinVersionNumber = m_cachedVersionNumber;
}
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("Min. Version NR.", GUILayout.MaxWidth(150));
if (m_cachedMinVersionNumber <= 1) GUI.enabled = false;
if (GUILayout.Button("-", GUILayout.MaxWidth(25))) ShiftMinVersion(-1);
if (!GUI.enabled) GUI.enabled = true;
EditorGUILayout.LabelField($"{m_cachedMinVersionNumber}", m_centeredLabel, GUILayout.MaxWidth(35));
if (m_cachedMinVersionNumber >= m_cachedVersionNumber) GUI.enabled = false;
if (GUILayout.Button("+", GUILayout.MaxWidth(25))) ShiftMinVersion(1);
if (!GUI.enabled) GUI.enabled = true;
EditorGUILayout.EndHorizontal();
}
else
{
m_cachedMinVersionNumber = m_cachedVersionNumber;
}
// Apply Changes
if (m_cachedVersionNumber == VersionControl.currentFileVersion &&
m_cachedMinVersionNumber == VersionControl.minFileVersion)
{
GUI.enabled = false;
}
if (GUILayout.Button("Apply")) UpdateVersionNumber();
if (!GUI.enabled) GUI.enabled = true;
// Source Folder Access
if (GUILayout.Button("Open Source Folder"))
System.Diagnostics.Process.Start(GetSaveFolderPath()!);
}
#endregion
#region Version Number Management
private void ShiftCurrentVersion(int increment)
{
m_cachedVersionNumber += increment;
if (!m_legacySupport)
{
m_cachedMinVersionNumber = m_cachedVersionNumber;
}
}
private void ShiftMinVersion(int increment)
{
m_cachedMinVersionNumber += increment;
}
private void UpdateVersionNumber()
{
VersionControl.currentFileVersion = m_cachedVersionNumber;
VersionControl.minFileVersion = m_cachedMinVersionNumber;
}
#endregion
#region Path Management
private string GetSaveFolderPath()
{
string basePath = Path.Combine(Application.persistentDataPath!, "GameData");
if (!Directory.Exists(basePath)) Directory.CreateDirectory(basePath);
return basePath;
}
#endregion
}
}
And to implement backwards compatibility you check the version number
if (version < 2 && version >= 1)
{
Dictionary<string, object> stateDict = (Dictionary<string, object>)state;
if (state.ContainsKey("position"))
transform.position = ((SerializableVector3)state["position"]).ToVector();
if (state.ContainsKey("rotation"))
transform.eulerAngles = ((SerializableVector3)state["rotation"]).ToVector();
}
else if (version > 2)
{
MoverSaveData data = (MoverSaveData)state;
transform.position = data.position.ToVector();
transform.eulerAngles = data.rotation.ToVector();
}
Nicely done!
I had to make a slight change to this as after coming back to this in Unity 2021.3.9f1, when entering play mode it kept resting the static variables back to the original. I am not sure if maybe I just had or of the scripts open in Rider and every time I entered Play Mode it recompiled the Save System, or if something changed in between one of the Unity versions. To Fix this issue I just wrote the Version.cs script to have the new values when applying the changes.
private void UpdateVersionNumber()
{
VersionControl.CurrentFileVersion = _cachedVersionNumber;
VersionControl.MinFileVersion = _cachedMinVersionNumber;
var files = Directory.EnumerateFiles(Directory.GetCurrentDirectory(), "VersionControl.cs",
SearchOption.AllDirectories).Where(f => File.ReadAllText(f).Contains("namespace RPGEngine.Saving"));
foreach (var file in files)
{
if (!File.Exists(file)) continue;
//HARDCODED VERSION UPDATE (Ugly but saves playing around with reading textfiles)
string[] code =
{
"namespace RPGEngine.Saving",
"{",
"\tpublic static class VersionControl",
"\t{",
$"\t\tpublic static int CurrentFileVersion = {_cachedVersionNumber};",
$"\t\tpublic static int MinFileVersion = {_cachedMinVersionNumber};",
"\t}",
"}"
};
File.WriteAllLines(file, code);
}
}
I also updated the Save System to use the JToken and strategy from the Advanced Saving System topic.
May I ask when is the best time to use Application.persistentDataPath vs Application.dataPath for saving the file?
And of course, Iāve been really super happy with how the Saving System was implemented. Thank you, Sam! Iāve always been amazed at how you coded and designed this system!
For saving data between sessions, you always want to use Application.persistentDataPath. You are generally guaranteed read/write asset to this folder on all platforms. The same cannot be said for Application.dataPath, which is basically the location of the game itself. If you are updating the game with content, you may try to use dataPath, although without permissions, youāll likely be denied write access (or youāll wind up with the OS putting up a permissions dialogue).
Never use Application.dataPath for saving game state between sessions. This is what peristentDataPath is for (and in my opinion, itās also where I would store updated content).
Thank you very much, @Brian_Trotter! Your info and insight on this is very helpful!
Does it also apply for a WebGL build? Say for example, uploading a new build to itch.io, would the existing save file still remain (using Application.persistentDataPath) even after uploading a new updated build? Although I think when clearing browser cache, it also removes the save file with it, but I kind of wanna know what the behavior is when uploading a new build. Would love to know your insights on this
WebGL is itās own monster. The persistent data path is partially a hash of the URL of the game. Many users have reportedā¦ extreme frustration with the persistent data path in WebGL.
Guess maybe Iām one of those users with extreme frustration about persistent data path in WebGL haha! Anyway, thank you very much for sharing your thoughts on this, Brian!
Sam you made me jealous. I decided that I want to learn how to build the saving system from scratch and I learned so much from this section about C# patterns and in general the process of developing a complex gamming system. there is a lot of creativity in finding all this solutions to all the special cases in order for it to work properly : the way you use the dictionaries , the unique identifier , flipping the method upside down etc and making every part of the system independent so it can āsave itselfā and take care of it own state.
I know its only a prototype because of the binary formatter issue but its gave me a good programing experience. So overall Thanks!
Hi, Saving System that was laid out is so very cool! Iāve watched, Iāve learned basic API, all of the other lectures about building it from ground up, and then the article about changing it to JSonSavingSystem.
Although I canāt say I understand everything, but with some determination Iāve managed to implement this system into my RTS project Iām working on.
Here is some flex video showing of that it works.
First you see me ordering bunch of units to Attack Move behind enemy unit. Then I press save button. After combat Iāve trained some new units, and then hit load button. As you can see everything returns to the moment when it was saved. Iām so happy for that it is working :P.
This Saving System part of lecture is really great, although to achieve destroying and instantiaing game objects I had to implement Object Manager class.
So first off, thanks for creating this lecture. Thereās so much good stuff and gotchas here and I think you did a great job exposing us to so much of it. Itās great from an academic perspective. I thought the approach for the PersistentObjectSpawner was also quite clever.
While it was great from an academic perspective, I think from a practical perspective the content felt a little unintuitive as to why certain things are a certain way. Even just saying something to the effect of
Example: Hey thereās a few things a full save system should have. A, B, C are covered here. Topics X, Y, Z are not covered in this section but rest assured weāre covering them all in upcoming sections. Weāre leaving D, E, and F as exercises for the student to explore if you want to. We recommend you wait until after section ## before exploring these modifications however.
would be enough though it would be even better to include a high level design before jumping into the code. I think it would help students who want to make their own modifications and also with doing the challenges.
Briefly hereās what Iām wondering if itās covered in either the JSON conversion or later on.
- Saving an arbitrary directed graph (e.g. including avoiding circular references or saving something only once even if multiple things point to it)
- Separating the autosave feature from the user triggered save feature. The fact they both save/load to the same thing is a bit unintuitive.
- more complex ISaveables. This is not hard but I think showing an example of serializing an ISaveable that has multiple properties would be beneficial. I feel JSON very well suited here.
- saving state for spawned or dynamically generated game data [I think I saw this in inventory course?]
Going into detail at the start of a section as to the scope of whatās covered or not covered within that section would be a great benefit I think.
We should only ever be saving the master record, for example, the characterās level should only be saved in Level, and never cached or saved in Health.
In the final course wrap up (Shops and Abilties) we create a menu system, where you can have multiple save files. The autosave is still in effect, using whatever your current save is.
As the courses progress, weāll be saving Dictionaries, arrays, and other structures. These things are extremely well suited for JObjects and JArrays and my tutorial will walk you through these structures with ease.
Thatās covered in Inventory as well as the equivalent section of the Json section.