How Emenite’s Game Data is Saved



I am working on an upcoming crafting survival game called Emenite. The game is running on the Unity game engine. An important part of any survival game like Emenite is the ability for the player to save their progress and return to the game at a later point. A game save in Emenite is called a world. In Emenite the player can have multiple saves at once each containing the state of the game world. Here is a brief overview of some of the world information that we save in Emenite:
- The general save information such as the version of the save, the world name, the save date, and the world generator random number generator seed.
- The ID, foreground color, and background color for each pixel of the terrain.
- Player character data.
- Any game data that objects in the world want to save.
- The global state of the world such as the time of day.
We will review how the save information and game object data are saved in detail.
Saving The World
The first file that we save for a world is the save information file. This file contains important metadata about the world. We save the world name, save version, save date, and world generator seed in this file. The file uses the JSON format for easy readability.
First, we need a class to hold the save information. The class that Emenite uses looks something like this:
[System.Serializable]
public class SaveInfo
{
public string name;
public uint saveVersion;
public DateTime saveDate;
public int seed;
}
The System.Serializable attribute allows the class to be serialized by the JSON serializer.
Now that we have our SaveInfo class let’s serialize it and write it to the disk. We first need a function to save our game. Then we need to create the directory for our game.
using System;
using System.IO;
public static class GameSaveLoad
{
public static void SaveGame(string worldName, string saveFolderLocation)
{
Directory.CreateDirectory(saveFolderLocation);
}
}
For the saveFolderLocation variable we need a folder to save our world data files. For that we will create a few new functions that will help us get the save folder. In our GameSaveLoad class we will define the following:
private static string saveFolderName = "Saves";
public static string GetSaveFilesPath()
{
return Path.Combine(Application.persistentDataPath, saveFolderName);
}
public static string GetSaveFolderLocation(string saveName)
{
return Path.Combine(GetSaveFilesPath(), saveName);
}
Along with a new using directive:
using System;
using System.io;
using UnityEngine;
Now when we call the SaveGame function we can pass the result of the GetSaveFolderLocation function for the saveFolderLocation. The reason why we do not simply use the name of the world as part of the save folder path is because the folder name and the name of the world can be different. For example if the player creates a new world and names it “My Fantastic World...” the periods at the end of the name cannot be used as part of a Windows folder name. This way we can sanitize the name of the world to create a valid folder name if we want to.
We have created a folder for the save now we will save the save information file by adding the following to our SaveGame function:
public static void SaveGame(string worldName, string saveFolderLocation)
{
Directory.CreateDirectory(saveFolderLocation);
SaveInfo saveInfo = new SaveInfo() {
name = worldName,
saveVersion = 0,
saveDate = DateTime.now,
seed = 0
};
using (FileStream fileStream = File.Open(Path.Combine(saveFolderLocation, "info.json"), FileMode.Create))
{
using StreamWriter streamWriter = new(fileStream);
streamWriter.Write(JsonConvert.SerializeObject(saveInfo, Formatting.Indented));
}
}
Also add a new using directive for NewtonSoft Json:
using System;
using System.io;
using UnityEngine;
using Newtonsoft.Json;
We create the “info.json” file in our save folder. The saveInfo object is serialized using the NewtonSoft Json Unity package and written to the file. We make sure to pass the Formatting.Indented option to the serialization function to make the JSON output more readable. The using directives make sure that the file stream and the stream writer are closed automatically. This makes sure that we do not accidentally leave files open.
Now that our save information file is saved it is time to look at how the various dynamic game objects that make up the world are saved. In Emenite I mark all prefabs that are instantiated at runtime that need to be saved as addressable. Then when these game objects are saved the game objects addressable reference string is saved. Later when the saved world is loaded we can instantiate the game object by its addressable reference.
Let’s create the following helper class for getting an addressable’s address in the Unity editor:
using UnityEditor;
using UnityEngine.AddressableAssets;
public static class AddressableUtils
{
#if UNITY_EDITOR
public static AssetReference GetAssetReferenceFromPrefab(Object target)
{
string path = AssetDatabase.GetAssetPath(target);
string guid = AssetDatabase.AssetPathToGUID(path);
AssetReference reference = new AssetReference(guid);
return reference;
}
#endif
}
Now we can create a class for storing the addressable’s address of a game object in the editor:
using UnityEngine;
using UnityEngine.AddressableAssets;
using System.IO;
using System.Text;
using UnityEditor;
[DisallowMultipleComponent]
public class AddressableRef : MonoBehaviour
#if UNITY_EDITOR
,ISerializationCallbackReceiver
#endif
{
public AssetReference reference;
[ReadOnly]
public string address;
#if UNITY_EDITOR
void OnValidate()
{
if (reference == null)
FindReference();
}
void FindReference()
{
reference = AddressableUtils.GetAssetReferenceFromPrefab(gameObject);
EditorUtility.SetDirty(gameObject);
}
public void OnAfterDeserialize()
{
}
public void OnBeforeSerialize()
{
try
{
if (reference != null)
address = AddressableUtils.GetAddressFromRef(reference);
}
catch
{
}
}
#endif
}
A paragraph about how the above script works.
using UnityEngine;
[RequireComponent(typeof(AddressableRef)), DisallowMultipleComponent]
public class SaveObject : MonoBehaviour
{
[HideInInspector]
public AddressableRef addressable;
private void Awake()
{
addressable = GetComponent<AddressableRef>();
}
}
Saving random objects in the binary format and Gzip compression
Loading The World
Loading save information
Loading random objects in the binary format and Gzip decompression
Conclusion
Reflection
The gameobject addressable addresses could be saved in a dictionary so we can save space by only saving the ids for the dictionary. This would save space but I do not know how much. The Gzip compression might be saving this space for us already.
Asynchronous Saving
Write to a memory stream and then write to the disk later in a separate thread or asynchronously.
Asynchronous Loading
Create objects in the world while the game is still running and keep them paused until everything is ready to go.