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.
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:
- “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.
- 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.
- 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.
// 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”.
// 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.
// 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