Devlog: The Great Refactor – Taming Scriptable Objects for a Robust Save System

Ahoy, fellow Dwarves!

Today, I want to take you on a deep dive into the engine room of Into The Deep. While I’ve been busy forging the levels for Act 1, a huge effort has been underway in the background: a complete and critical refactoring of the game’s core architecture, specifically how it handles and saves your progress.

The Glorious Trap of Scriptable Objects

Anyone who has worked with Unity loves ScriptableObjects (SOs). They are a blessing for prototyping. You can create data containers as assets, drag-and-drop references in the Inspector, and everything just works. It’s wonderfully simple.

The one that started it all. Like many Unity developers, I was completely blown away by Ryan Hipples ideas and completely changed my way of prototyping by using referenced scriptable objects. No problem for prototypes… but for persistent game data.

My initial approach for save games was built entirely on this structure. I had a PlayerData SO, which held a reference to a CrewSO, which in turn held references to CharacterSOs. When Amos took damage during a playtest, my code would simply change the health value directly inside the Amos_CharacterSO asset. Convenient? Absolutely. A good long-term idea? Not so much.

I had walked right into the classic trap: I was using SOs, which should be templates, as storage for mutable runtime data. It’s like building a house and then drawing on the original blueprint with a marker to show where you put your couch. The next time you use that blueprint, you’ll be wondering why there’s a couch already drawn on it.

The Cracks in the Foundation

This approach worked for a quick demo, but as I started building a real game, the problems became glaringly obvious:

  1. “Dirty” Project Assets: This was the biggest headache. If Amos died in Play Mode and his health was set to 0 in the SO, it stayed at 0 after I exited Play Mode. I had to manually reset my project data constantly to get a clean test run. This is error-prone and incredibly annoying.
  2. No Multiple Save Slots: How could I possibly manage three separate save files when they all reference and modify the exact same CharacterSO assets? It’s impossible. Each save would effectively overwrite the state of the others.
  3. The Serialization Dilemma: To save a game to a file (like a .json), you need to serialize your data. Unity’s JsonUtility can only serialize simple data types (strings, ints, floats, etc.). It has no idea how to convert a direct reference to a project asset like a ScriptableObject into text. That reference is just a “pointer” inside the engine; in a text file, it’s meaningless.

It was clear: the system had to be rebuilt from the ground up.

The Solution: The Great Separation of Template and State

The new system is built on one fundamental principle: a strict separation between Template Data (the blueprint) and State Data (the current save game).

1. The Blueprint (Template):

My ScriptableObjects (CharacterSO, WeaponSO, etc.) are now treated as pure, read-only templates. They only contain the base values: a character’s name, their maximum health, a weapon’s base damage. They are never modified by code during runtime.

C#
// CharacterSO.cs - The Template (Read-Only)
[CreateAssetMenu]
public class CharacterSO : ScriptableObject
{
    public string characterID; // e.g., "amos_eisenfaust"
    public string characterName;
    public float maxHealth;
    // ...other base stats...
}

2. The State (Instance):

For the data that changes, I’ve created new, simple C# classes (often called POCOs) that don’t inherit from anything and just hold data.

  • CharacterState: Stores only what changes for a character, like currentLevel, currentXP, currentHP.
  • GameSaveData: This is the main container for a complete save file. It holds lists of these State objects (e.g., List<CharacterState> squadStates).

The crucial trick is that these State classes don’t store direct references to the SO assets. Instead, they only store a simple string ID. So, Amos’s CharacterState doesn’t contain the Amos_CharacterSO asset, it just contains the string “amos_eisenfaust”.

C#
// CharacterState.cs - The Saved Data
[System.Serializable]
public class CharacterState
{
    public string templateID; // The "link" back to the CharacterSO
    public float currentHP;
    public int currentLevel;
    // ...other dynamic data...
}

How It All Works: IDs and The Database

To make this work, I created a central GameAssetDatabase. This is a single ScriptableObject that holds references to all my template SOs (characters, weapons, levels, etc.).

The new save/load flow looks like this:

  • Saving: A DataManager collects all the State objects (from all characters, quests, etc.), bundles them into a single GameSaveData object, and serializes it into a JSON file. The string IDs can be saved perfectly.
  • Loading: The DataManager reads the JSON file and reconstructs the GameSaveData object in memory.
  • Instantiating: A SquadManager looks at the list of loaded CharacterState objects. For each one, it takes the templateID (e.g., “amos_eisenfaust”), goes to the GameAssetDatabase and asks, “Give me the CharacterSO with this ID.” Once it has the template, it spawns the character’s prefab and applies the loaded state (currentHP, Level, etc.) to it.

This pattern of separating data and referencing assets via IDs wasn’t entirely new to me. My previous explorations into multiplayer with Netcode for Gameobjects use a very similar approach, as you can’t send complex object references over the network—only serializable data like IDs. That experience really taught me to appreciate this robust, decoupled architecture.

The Best of Both Worlds: Making Testing Easy Again

The biggest drawback of this clean system is losing the convenience of setting up test scenarios in the Inspector. I can no longer just drag a “Test_Savegame_SO” into a manager.

My solution is an Editor-only tool. I’ve created a special SaveFilePresetSO that only exists inside the Unity Editor. Here, I can drag-and-drop CharacterSOs, set test values (Level 5, 1000 gold, etc.) just like before. A custom button in the Inspector, “Generate JSON Save File”, then converts this preset into a clean, loadable .json save file.

C#
// SaveFilePresetSO.cs - An Editor-Only Tool
[CreateAssetMenu]
public class SaveFilePresetSO : ScriptableObject
{
    [Header("Test-Setup")]
    public int saveSlotIndex = 99;
    public int currency = 1000;
    public List<CharacterPreset> crewRoster; // Can drag CharacterSOs in here!
    // ...
}

[System.Serializable]
public class CharacterPreset
{
    public CharacterSo characterTemplate; // Direct SO reference for editor convenience
    public int level = 5;
}

This gives me the old convenience without compromising the clean runtime architecture.

The Moral of the Story

If there’s a lesson here, it’s this: Refactor sooner rather than later. The initial comfort of a “hacky” prototyping solution creates a massive “technical debt”. Unraveling that architecture later is an enormous and sometimes frustrating task, as it touches almost every single system in the game.

But there’s a silver lining: being forced to go through every class, from CharacterUnit to GameManager, also gives you a unique opportunity to clean up, improve, and solidify your entire codebase. It’s safe to say this ID-based architecture will be my standard practice for all future projects. The days of using ScriptableObjects for dynamic data are definitely over!

Thanks for reading!

Tobi

C# / Unity Tip: Embedding quotes within a string

We all know how it goes. As soon as we are adding a bit of juice to our strings, it get’s messy. As I was building a modal for my UI System and writing down some placeholder texts, my OCD began to tingle.

C#
string message = $"Do you want to leave this level? \n Loot collected: {coinpercent} %  <sprite=\"uiicons\" name=\"coin\">";

I dont know why, but the standard “escape with backslash” approach is somewhat unreadable for me. One method would be to shift the problem and use string interpolation like that example below, but that also makes it a bit more complicated.

C#
string spriteTag = "<sprite=\"uiicons\" name=\"coin\">";
string title = $"Do you want to leave this level? \n Loot collected: {coinpercent} % {spriteTag}";

Precede with @ and use double quotes

A quick google search later (ChatGPT failed) I found another approach, that I was unaware of.

If you precede the string with the “@” character, you can use a double set of quotation marks to indicate a single set of quotation marks within a string. If you prefix the string with the “@” character, you can use a set of double quotation marks to specify a single set of quotation marks within a string. And it makes it more readable. Have a look:

C#
//Standard Method
string message = $"Do you want to leave this level? \n Loot collected: {coinpercent} %  <sprite=\"uiicons\" name=\"coin\">";

//String Interpolation
string spriteTag = "<sprite=\"uiicons\" name=\"coin\">";
string title = $"Do you want to leave this level? \n Loot collected: {coinpercent} % {spriteTag}";

//@Method
string message = $@"Do you want to leave this level? 
Loot collected: {coinpercent} % <sprite=""uiicons"" name=""coin"">";

One Caveat however: The @ character in C# treats the string as a verbatim string literal, which means it includes all whitespace and line breaks exactly as they are written. This includes the indentation at the start of the line. So if you are in Code and use indentation, it gets shifted.

But nevertheless. Cool to learn some different approaches.

C# / Unity Tip: Get the index from end of an array or list

So, I was in the midst of developing my game when I wanted to retrieve the penultimate (I looked it up, it exists) member of my pathfinding array to determine if the point before the ULTIMATE target is reachable. So, naturally, I used the old self-referencing line to get the work done and created a static method to do the job.

C#
        ...PathExtensions.GetPositionOnPath(path, path.vectorPath.Count -2)...        
        
        /// Method to retrieve the index from the end
        
        public static Vector3 GetPositionOnPath(Path path, int i)
        {
            return path.vectorPath[i];
        }

But wait! Something in the deeper parts of my brain itched. There was some other way to achieve this much more elegantly. And yes, I was right! Again!

INTRODUCING THE ^_____^

After a quick Google search, I found the answer. With C# 8.0 and later, you can indeed use the caret symbol (^) to index arrays, which is known as the “index from end” feature. Nice! It is now much more readable and easier to maintain.

C#
        ...PathExtensions.GetPositionOnPath(path,^2)...
        
        /// Much simpler method to retrieve the index from the end
        
        public static Vector3 GetPositionOnPath(Path path, Index i)
        {
            return path.vectorPath[i];
        }

Some examples:

C#
        string[] colors = { "red", "blue", "green", "yellow", "orange", "purple", "pink", "brown", "black", "white" };

        // Accessing the last element of the array:
        Debug.Log(colors[^1]);
        // Output: "white"

        // Accessing the second to last element of the array:
        Debug.Log(colors[^2]);
        // Output: "black"

        // Accessing the third element of the array:
        Debug.Log(colors[^3]);
        // Output: "brown"

        // Accessing the first element of the array:
        Debug.Log(colors[^10]);
        // Output: "red"

Hope that helps! Bye.

Official documentation:

https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/member-access-operators#index-from-end-operator-