Persisting Objects
Creating, Saving, and Loading
- Spawn random cubes in response to a key press.
- Use a generic type and virtual methods.
- Write data to a file and read it back.
- Save the game state so it can be loaded later.
- Encapsulate the details of persisting data.
This is the first tutorial in a series about managing objects. It covers creating, tracking, saving, and loading simple prefab instances. It builds on the foundation laid by the tutorials in the Basics section.
This tutorial is made with Unity 2017.3.1p4.
Creating Objects On Demand
You can create scenes in the Unity editor and populate them with object instances. This allows you to design fixed levels for your game. The objects can have behavior attached to them, which can alter the state of the scene while in play mode. Often, new object instances are created during play. Bullets are fired, enemies spawn, random loot appears, and so on. It might even be possible for players to create custom levels inside the game.
Creating new stuff during play is one thing. Remembering it all, so the player can quit and later return to the game is something else. Unity doesn't automatically keep track of the potential changes for us. We have to do that ourselves.
In this tutorial we'll create a very simple game. All it does is spawn random cubes in response to pressing a key. Once we're able to keep track of the cubes between play sessions, we can increase the game's complexity in a later tutorial.
Game Logic
Because our game is so simple, we'll control it with a single Game
component script. It will spawn cubes, for which we'll use a prefab. So it should contain a public field to hook up a prefab instance.
using UnityEngine; public class Game : MonoBehaviour { public Transform prefab; }
Add a game object to the scene and attach this component to it. Then also create a default cube, turn it into a prefab, and give the game object a reference to it.
Player Input
We're going to spawn cubes in response to player input, so our game must be able to detect this. We'll use Unity's input system to detect key presses. Which key should be used to spawn a cube? The C key seems appropriate, but we can make this configurable via the inspector, by adding a public KeyCode
enumeration field to Game
. Use C as the default option when defining the field, via an assignment.
public KeyCode createKey = KeyCode.C;
We can detect whether the key is pressed by querying the static Input
class in an Update
method. The Input.GetKeyDown
method returns a boolean that tells us whether a specific key was pressed in the current frame. If so, we have to instantiate our prefab.
void Update () { if (Input.GetKeyDown(createKey)) { Instantiate(prefab); } }
Randomized Cubes
While in play mode, our game now spawns a cube each time we press the C key, or whichever key you configured it to respond to. But it looks like we only get a single cube, because they all end up at the same position. So let's randomize the position of each cube that we create.
Keep track of the instantiated Transform
component, so we can change its local position. Use the static Random.insideUnitSphere
property to get a random point, scale it up to a radius of five units, and use that as the final position. Because that's more work than just a trivial instantiation, put the code for that in a separate CreateObject
method and invoke it when the key is pressed.
void Update () { if (Input.GetKeyDown(createKey)) {// Instantiate(prefab);CreateObject(); } } void CreateObject () { Transform t = Instantiate(prefab); t.localPosition = Random.insideUnitSphere * 5f; }
The cubes now spawn inside a sphere instead of all at the exact same position. They can still overlap, but that's fine. However, they're all aligned and that doesn't look interesting. So let's give each cube a random rotation, for which we can use the static Random.rotation
property.
void CreateObject () { Transform t = Instantiate(prefab); t.localPosition = Random.insideUnitSphere * 5f; t.localRotation = Random.rotation; }
Finally, we can also vary the size of the cubes. We'll use uniformly-scaled cubes, so they're always perfect cubes, just with different sizes. The static Random.Range
method can be used to get a random float
inside a certain range. Let's go from small size 0.1 cubes up to regular size 1 cubes. To use this value for all three dimensions of the scale, simple multiply Vector3.one
with it, then assign the result to the local scale.
void CreateObject () { Transform t = Instantiate(prefab); t.localPosition = Random.insideUnitSphere * 5f; t.localRotation = Random.rotation; t.localScale = Vector3.one * Random.Range(0.1f, 1f); }
Starting a New Game
If we want to begin a new game, we have to exit play mode and then enter it again. But that's only possible in the Unity Editor. The player would need to quit our app and start it again to be able to play a new game. It's much better if we could begin a new game while remaining in play mode.
We could start a new game by reloading the scene, but this isn't necessary. We can suffice with destroying all the cubes that were spawned. Let's use another configurable key for that, using N as the default.
public KeyCode createKey = KeyCode.C; public KeyCode newGameKey = KeyCode.N;
Check whether this key is pressed in Update
, and if so invoke a new BeginNewGame
method. We should only handle one key at a time, so only check for the N key if the C key isn't pressed.
void Update () { if (Input.GetKeyDown(createKey)) { CreateObject(); } else if (Input.GetKey(newGameKey)) { BeginNewGame(); } } void BeginNewGame () {}
Keeping Track of Objects
Our game can spawn an arbitrary number of randomized cubes, which all get added to the scene. But Game
has no memory of what it spawned. In order to destroy the cubes, we first need to find them. To make this possible, we'll have Game
keep track of a list of references to the objects it instantiated.
We could add an array field to Game
and fill it with references, but we don't know ahead of time how many cubes will be created. Fortunately, the System.Collections.Generic
namespace contains a List
class that we can use. It works like an array, except that its size isn't fixed.
using System.Collections.Generic; using UnityEngine; public class Game : MonoBehaviour { … List objects; … }
But we don't want a generic list. We specifically want a list of Transform
references. In fact, List
insists that we specify the type of its contents. List
is a generic type, which means that it acts like a template for specific list classes, each for a concrete content type. The syntax is List<T>
, where the template type T
is appended to the generic type, between angle brackets. In our case the correct type is List<Transform>
.
List<Transform> objects;
Like an array, we have to ensure that we have a list object instance before we use it. We'll do that by creating the new instance in the Awake
method. In the case of an array, we'd have to use new Transform[]
. But because we're using a list, we have to use new List<Transform>()
instead. This invokes the special constructor method of the list class, which can have parameters, which is why we have to append round brackets after the type name.
void Awake () { objects = new List<Transform>(); }
Next, add a Transform
reference to our list each time we instantiate a new one, via the Add
method of List
.
void CreateObject () { Transform t = Instantiate(prefab); t.localPosition = Random.insideUnitSphere * 5f; t.localRotation = Random.rotation; t.localScale = Vector3.one * Random.Range(0.1f, 1f); objects.Add(t); }
Clearing the List
Now we can loop through the list in BeginNewGame
and destroy all the game objects that were instantiated. This works the same as for array, except that the length of the list is found via its Count
property.
void BeginNewGame () { for (int i = 0; i < objects.Count; i++) { Destroy(objects[i].gameObject); } }
This leaves us with a list of references to destroyed objects. We must get rid of these as well, by emptying the list via invoking its Clear
method.
void BeginNewGame () { for (int i = 0; i < objects.Count; i++) { Destroy(objects[i].gameObject); } objects.Clear(); }
Saving and Loading
To support saving and loading during a single play session, it would be sufficient to keep a list of transformation data in memory. Copy the position, rotation, and scale of all cubes on a save, and reset the game and spawn cubes using the remembered data on a load. However, a true save system is able to remember the game state even after the game is terminated. This requires the game state to be persisted somewhere outside the game. The most straightforward way is to store the data in a file.
Save Path
Where game files should be stored depends on the file system. Unity takes care of the differences for us, making the path to the folder that we can use available via the Application.persistentDataPath
property. We can grab the text string from this property and store it in a savePath
field in Awake
, so we need to retrieve it only once.
string savePath; void Awake () { objects = new List<Transform>(); savePath = Application.persistentDataPath; }
This gives us the path to a folder, not a file. We have to append a file name to the path. Let's just use saveFile, not bothering with a file extension. Whether we should use a forward or backward slash to separate the file name from the rest of the path again depends on the operating system. We can use the Path.Combine
method to take care of the specifics for us. Path
is part of the System.IO
namespace.
using System.Collections.Generic; using System.IO; using UnityEngine; public class Game : MonoBehaviour { … void Awake () { objects = new List<Transform>(); savePath = Path.Combine(Application.persistentDataPath, "saveFile"); } … }
Opening a File for Writing
To be able to write data to our save file, we first have to open it. This is done via the File.Open
method, providing it with a path argument. It also needs to know why we're opening the file. We want to write data to it, creating the file if it didn't already exist, or replacing an already existing file. We specify this by providing FileMode.Create
as a second argument. Do this in a new Save
method.
void Save () { File.Open(savePath, FileMode.Create); }
File.Open
returns a file stream, which isn't useful on its own. We need a data stream that we could write data into. This data has to be of a certain format. We'll use the most compact uncompressed format available, which is raw binary data. The System.IO
namespace has the BinaryWriter
class to make this possible. Create a new instance of this class, using its constructor method, providing the file stream as an argument. We don't need to keep a reference to the file stream, so we can directly use the File.Open
invocation as the argument. We do need to keep a reference to the writer, so assign it to a variable.
void Save () { BinaryWriter writer = new BinaryWriter(File.Open(savePath, FileMode.Create)); }
We now have a binary writer variable named writer that references a new binary writer. That's using the word "writer" three times in one expression, which is a bit much. As we're explicitly creating a new BinaryWriter
, it is redundant to explicitly declare the variable's type as well. Instead, we can use the var
keyword. This implicitly declares the variable's type to match whatever is immediately assigned to it, which is something that the compiler can figure out in this case.
void Save () { var writer = new BinaryWriter(File.Open(savePath, FileMode.Create)); }
We now have a writer variable that references a new binary writer. Its type is obvious.
Closing the File
If we open a file, we must make sure that we also close it. It's possible to do this via a Close
method, but this isn't safe. If something goes wrong between opening and closing the file, an exception could be raised and execution of the method could be terminated before it got to closing the file. We have to carefully handle exceptions to ensure that the file is always closed. There is syntactic sugar to make this easy. Put the declaration and assignment of the writer
variable inside round brackets, place the using
keyword in front of it, and a code block after it. The variable is available inside that block, just like the iterator variable i
of a standard for
loop.
void Save () { using ( var writer = new BinaryWriter(File.Open(savePath, FileMode.Create)) ) {} }
This will ensure that whatever writer
references will be properly disposed of, after code execution exits the block, no matter how. This works for special disposable types, which the writer and stream are.
Writing Data
We can write data to our file by invoking our writer's Write
method. It is possible to write simple values, like a boolean, integer, and so on, one at a time. Let's begin by writing only how many objects we have instantiated.
void Save () { using ( var writer = new BinaryWriter(File.Open(savePath, FileMode.Create)) ) { writer.Write(objects.Count); } }
To actually save this data, we have to invoke the Save
method. We'll again control this via a key, in this case using S as the default.
public KeyCode createKey = KeyCode.C; public KeyCode saveKey = KeyCode.S; … void Update () { if (Input.GetKeyDown(createKey)) { CreateObject(); } else if (Input.GetKey(newGameKey)) { BeginNewGame(); } else if (Input.GetKeyDown(saveKey)) { Save(); } }
Enter play mode, create a few cubes, then save the game by pressing the key. This will have created a saveFile file on your file system. If you're not sure where it is located, you can use Debug.Log
to write the file's path to the Unity console.
You'll find that the file contains four bytes of data. Opening the file in a text editor will show nothing useful, because the data is binary. It might show nothing at all, or might interpret the data as weird characters. There are four bytes because that's the size of an integer.
Besides writing how many cubes we have, we must also store the transformation data of each cube. We do this by looping through the objects and writing their data, one number at a time. For now, we'll limit ourselves to just their positions. So write the X, Y, and Z components of each cube's position, in that order.
writer.Write(objects.Count); for (int i = 0; i < objects.Count; i++) { Transform t = objects[i]; writer.Write(t.localPosition.x); writer.Write(t.localPosition.y); writer.Write(t.localPosition.z); }
Loading Data
To load the data that we just saved, we have to again open the file, this time with FileMode.Open
as the second argument. Instead of a BinaryWriter
, we have to use a BinaryReader
. Do this in a new Load
method, once again with a using
statement.
void Load () { using ( var reader = new BinaryReader(File.Open(savePath, FileMode.Open)) ) {} }
The first thing we wrote to the file was the count property of our list, so that is also the first thing to read. We do this with the ReadInt32
method of our reader. We have to be explicit what we read, because there is no parameter that makes this clear. The suffix 32 refers to the size of the integer, which is four bytes, thus 32 bits. There are also larger and smaller integer variants, but we don't use those.
using ( var reader = new BinaryReader(File.Open(savePath, FileMode.Open)) ) { int count = reader.ReadInt32(); }
After reading the count, we know how many objects were saved. We have to read that many positions from the file. Do this with a loop, reading three floats per iteration, for the X, Y, and Z components of a position vector. A single-precision float
is read with the ReadSingle
method. A double-precision double
would be read with the ReadDouble
method.
int count = reader.ReadInt32(); for (int i = 0; i < count; i++) { Vector3 p; p.x = reader.ReadSingle(); p.y = reader.ReadSingle(); p.z = reader.ReadSingle(); }
Use the vector to set the position of a newly instantiated cube, and add it to the list.
for (int i = 0; i < count; i++) { Vector3 p; p.x = reader.ReadSingle(); p.y = reader.ReadSingle(); p.z = reader.ReadSingle(); Transform t = Instantiate(prefab); t.localPosition = p; objects.Add(t); }
At this point we can recreate all cubes that we saved, but they get added to the cubes that were already in the scene. To properly load a previously saved game, we have to reset the game before recreating it. We can do this by invoking BeginNewGame
before loading the data.
void Load () { BeginNewGame(); using ( var reader = new BinaryReader(File.Open(savePath, FileMode.Open)) ) { … } }
Have Game
invoke Load
when a key is pressed, using L as the default.
public KeyCode createKey = KeyCode.C; public KeyCode saveKey = KeyCode.S; public KeyCode loadKey = KeyCode.L; … void Update () { … else if (Input.GetKeyDown(saveKey)) { Save(); } else if (Input.GetKeyDown(loadKey)) { Load(); } }
Now the player can save their cubes and later load them, either during the same play session or another one. But because we're only storing the position data, the rotation and scale of cubes are not stored. As a result, loaded cubes all end up with the default rotation and scale of the prefab.
Abstracting Storage
Although we need to know the specifics of reading and writing binary data, that's rather low-level. Writing a single 3D vector requires three invocations of Write
. When saving and loading our objects, it's more convenient if we could work at a slightly higher level, reading or writing an entire 3D vector with a single method invocation. Also, it would be nice if we could just use ReadInt
and ReadFloat
, instead of having to worry about all the different variants that we don't use. Finally, it shouldn't matter whether the data is stored in binary, plain text, base-64, or another encoding method. Game
doesn't need to know such details.
Game Data Writer and Reader
To hide the details of reading and writing data, we're going to create our own reader and writer classes. Let's begin with the writer, naming it GameDataWriter
.
GameDataWriter
does not extend MonoBehaviour
, because we won't attach it to a game object. It will act as a wrapper for BinaryWriter
, so give it a single writer field.
using System.IO; using UnityEngine; public class GameDataWriter { BinaryWriter writer; }
A new object instance of our custom writer type can be created via new GameDataWriter()
. But this only makes sense if we have a writer to wrap. So create a custom constructor method with a BinaryWriter
parameter. This is a method with the type name of its class as its own name, which also acts as its return type. It replaces the implicit default constructor method.
public GameDataWriter (BinaryWriter writer) { }
Although invoking a constructor method results in a new object instance, such methods don't explicitly return anything. The object gets created before the constructor is invoked, which can then take care of any required initialization. In our case, that's simply assigning the writer parameter to the object's field. As I've used the same name for both, I have to use the this
keyword to explicitly indicate that I'm referring to the object's field instead of the parameter.
public GameDataWriter (BinaryWriter writer) { this.writer = writer; }
The most basic functionality is to write a single float
or int
value. Add public Write
methods for this, simply forwarding the invocation to the actual writer.
public void Write (float value) { writer.Write(value); } public void Write (int value) { writer.Write(value); }
Besides that, also add methods to write a Quaternion
—for rotations—and a Vector3
. These methods have to write all the components of their parameter. In the case of a quaternion, that's four components.
public void Write (Quaternion value) { writer.Write(value.x); writer.Write(value.y); writer.Write(value.z); writer.Write(value.w); } public void Write (Vector3 value) { writer.Write(value.x); writer.Write(value.y); writer.Write(value.z); }
Next, create a new GameDataReader
class, using the same approach as for the writer. In this case, we wrap a BinaryReader
.
using System.IO; using UnityEngine; public class GameDataReader { BinaryReader reader; public GameDataReader (BinaryReader reader) { this.reader = reader; } }
Give it methods that are simply named ReadFloat
and ReadInt
, that forward the invocations to ReadSingle
and ReadInt32
.
public float ReadFloat () { return reader.ReadSingle(); } public int ReadInt () { return reader.ReadInt32(); }
Also create ReadQuaternion
and ReadVector3
methods. Read their components in the same order that we write them.
public Quaternion ReadQuaternion () { Quaternion value; value.x = reader.ReadSingle(); value.y = reader.ReadSingle(); value.z = reader.ReadSingle(); value.w = reader.ReadSingle(); return value; } public Vector3 ReadVector3 () { Vector3 value; value.x = reader.ReadSingle(); value.y = reader.ReadSingle(); value.z = reader.ReadSingle(); return value; }
Persistable Objects
Now it's a lot simpler to write the transform data of cubes in Game
. But we can go one step further. What if Game
could simply invoke writer.Write(objects[i])
? That would be very convenient, but would require GameDataWriter
to know the details of writing a game object. But it's better to keep the writer simple, limited to primitive values and simple structs.
We can turn this reasoning around. Game
doesn't need to know how to save a game object, that's the responsibility of the object itself. All the object needs is a writer to save itself. Then Game
could use objects[i].Save(writer)
.
Our cubes are simple objects, without any custom components attached. So the only thing that's to save is the transform component. Let's create a PersistableObject
component script that knows how to save and load that data. It simply extends MonoBehaviour
and has a public Save
method and Load
method with a GameDataWriter
or GameDataReader
parameter respectively. Have it save the transform position, rotation, and scale, and load them in the same order.
using UnityEngine; public class PersistableObject : MonoBehaviour { public void Save (GameDataWriter writer) { writer.Write(transform.localPosition); writer.Write(transform.localRotation); writer.Write(transform.localScale); } public void Load (GameDataReader reader) { transform.localPosition = reader.ReadVector3(); transform.localRotation = reader.ReadQuaternion(); transform.localScale = reader.ReadVector3(); } }
The idea is that a game object that can be persisted only has one PersistableObject
component attached to it. Having multiple such components makes no sense. We can enforce this by adding the DisallowMultipleComponent
attribute to the class.
[DisallowMultipleComponent] public class PersistableObject : MonoBehaviour { … }
Add this component to our cube prefab.
Persistent Storage
Now that we have a persistent object type, let's also create a PersistentStorage
class to save such an object. It contains the same saving and loading logic as Game
, except that it only saves and loads a single PersistableObject
instance, provided via a parameter to public Save
and Load
methods. Make it a MonoBehaviour
, so we can attach it to a game object and it can initialize its save path.
using System.IO; using UnityEngine; public class PersistentStorage : MonoBehaviour { string savePath; void Awake () { savePath = Path.Combine(Application.persistentDataPath, "saveFile"); } public void Save (PersistableObject o) { using ( var writer = new BinaryWriter(File.Open(savePath, FileMode.Create)) ) { o.Save(new GameDataWriter(writer)); } } public void Load (PersistableObject o) { using ( var reader = new BinaryReader(File.Open(savePath, FileMode.Open)) ) { o.Load(new GameDataReader(reader)); } } }
Add a new game object to the scene with this component attached. It represents the persistent storage of our game. Theoretically, we could have multiple such storage objects, used to store different things, or to provide access to different storage types. But in this tutorial we use just this single file storage object.
Persistable Game
To make use of the new persistable object approach, we have to rewrite Game
. Change the prefab
and objects
content type to PersistableObject
. Adjust CreateObject
so it can deal with this type change. Then remove all the code specific to reading from and writing to files.
using System.Collections.Generic;//using System.IO;using UnityEngine; public class Game : MonoBehaviour { public PersistableObject prefab; … List<PersistableObject> objects;// string savePath;void Awake () { objects = new List<PersistableObject>();// savePath = Path.Combine(Application.persistentDataPath, "saveFile");} void Update () { … else if (Input.GetKeyDown(saveKey)) {// Save();} else if (Input.GetKeyDown(loadKey)) {// Load();} } … void CreateObject () { PersistableObject o = Instantiate(prefab); Transform t = o.transform; … objects.Add(o); }// void Save () {// …// }// void Load () {// …// }}
We'll have Game
rely on a PersistentStorage
instance to take care of the details of storing data. Add a public storage
field of this type, so we can give Game
a reference to our storage object. To again save and load the game state, we have Game
itself extend PersistableObject
. Then it can load and save itself, using the storage.
public class Game : PersistableObject { … public PersistentStorage storage; … void Update () { if (Input.GetKeyDown(createKey)) { CreateObject(); } else if (Input.GetKeyDown(saveKey)) { storage.Save(this); } else if (Input.GetKeyDown(loadKey)) { BeginNewGame(); storage.Load(this); } } … }
Connect the storage via the inspector. Also reconnect the prefab, as its reference was lost due to the field's type change.
Overriding Methods
When we save and load the game now, we end up writing and reading the transformation data of our main game object. This is useless. Instead, we have to save and load its list of objects.
Instead of relying on the Save
method defined in PersistableObject
, we have to give Game
its own public version of Save
with a GameDataWriter
parameter. In it, write the list as we did before, now using the convenient Save
method of the objects.
public void Save (GameDataWriter writer) { writer.Write(objects.Count); for (int i = 0; i < objects.Count; i++) { objects[i].Save(writer); } }
This is not yet enough to make it work. The compiler complains that Game.Save
hides the inherited member PersistableObject.Save
. While Game
can work with its own Save
version, PersistentStorage
only knows about PersistableObject.Save
. So it would invoke this method, not the one from Game
. To make sure that the correct Save
method gets invoked, we have to explicitly declare that we override the method that Game
inherited from PersistableObject
. That's done by adding the override
keyword to the method declaration.
public override void Save (GameDataWriter writer) { … }
However, we cannot just override any method we like. By default, we're not allowed to do this. We have to explicitly enable it, by adding the virtual
keyword to the Save
and Load
method declarations in PersistableObject
.
public virtual void Save (GameDataWriter writer) { writer.Write(transform.localPosition); writer.Write(transform.localRotation); writer.Write(transform.localScale); } public virtual void Load (GameDataReader reader) { transform.localPosition = reader.ReadVector3(); transform.localRotation = reader.ReadQuaternion(); transform.localScale = reader.ReadVector3(); }
PersistentStorage
will now end up invoking our Game.Save
method, even though it's passed to it as a PersistableObject
argument. Also have Game
override the Load
method.
public override void Load (GameDataReader reader) { int count = reader.ReadInt(); for (int i = 0; i < count; i++) { PersistableObject o = Instantiate(prefab); o.Load(reader); objects.Add(o); } }
The next tutorial is Object Variety.