6.6 Data-Driven Design, Save/Load

Phase 6 · Gameplay Programming · Study time: 25–40 h

Driving content from data (ScriptableObjects, JSON) instead of hard-coding it, and building robust save/load with versioning.

You already know how to write a C# class and put values in its fields. This chapter asks a different question: where should those values live? In every example so far, numbers went straight into a script — an enemy's health was typed as a number inside the Enemy script itself. That is fine for a five-minute prototype. It stops working the moment a real game has fifty enemy types, or the moment a game designer (someone who is not writing C#) needs to change a health value and cannot wait for a programmer to do it. This chapter covers data-driven design (keeping content in data instead of hard-coding it into scripts), Unity's ScriptableObject tool for that, and then the other half of the topic: saving a player's progress to disk and making sure that save still works after you patch the game.

1. What data-driven design means

Data-driven design means the specific numbers and content of your game (an enemy's health, a weapon's damage, the text of a line of dialogue, how much gold a chest drops) live in data — separate files or assets — instead of being typed as literal numbers inside your C# scripts. The script becomes generic: it reads whatever data it is given and acts on it. It does not know or care whether it is running for a goblin or a dragon; it just reads maxHealth from whatever data object was handed to it.

The opposite is hard-coding: writing the actual number straight into the code, like int maxHealth = 20; sitting inside a script called Goblin. If you want an orc too, the usual beginner move is to copy that script and change the numbers, giving you Goblin.cs, Orc.cs, Dragon.cs — three scripts that are 90% identical, differing only in the numbers.

Why this matters for designers and balancing

Balancing a game (tuning numbers like damage, health, and cost until the game feels fair and fun) is not a one-time job. You playtest, a fight feels too easy, you lower the enemy's health by 10%, you playtest again, you repeat this dozens of times per enemy. If every tweak means opening a C# file, changing a number, and waiting for Unity to recompile the script, that loop is slow — and it means only programmers can do it.

With data-driven design, the numbers live in an asset a designer can open and edit directly inside the Unity Editor, with no code and no recompiling. This has two big effects: programmers and designers can work at the same time without stepping on each other's files, and balancing becomes fast enough to do dozens of times in an afternoon. It also makes adding content cheap — a new enemy type is a new data asset with new numbers, not a new script.

Tip A useful test for "is this data-driven": could a designer who has never opened a C# file still add a new enemy or change a number, using only the Unity Editor? If yes, it is data-driven. If they need a programmer, it probably is not.

2. Unity's tool for this: ScriptableObject

A MonoBehaviour (the base class you already use for scripts on GameObjects) lives attached to a specific object in a specific scene. A ScriptableObject is different: it is a class that holds data as an asset — a file that lives in your Project folder, not attached to any GameObject or scene. You create it once, and any number of GameObjects across any number of scenes can point at that same asset and read its values.

Two things make ScriptableObjects convenient for data-driven design. First, the [CreateAssetMenu] attribute lets a designer right-click in the Project window and choose Create to make a new instance of that data — no code required. Second, because it is a real asset, it shows up in the Inspector like any other Unity object, with a friendly field-by-field editing view designers already know how to use.

MonoBehaviour data ScriptableObject data -------------------- ---------------------- lives ON a GameObject lives as its OWN asset file one copy per GameObject in the scene one shared asset, many GameObjects can all point at it gone when the scene unloads stays in the Project folder (loaded on demand, not tied to a scene)

A ScriptableObject is not attached to anything and never gets an Update() call — it just sits there holding values until something asks it for them. That makes it a good fit for content that does not change while the game runs: an enemy's base stats, an item's description, a weapon's damage. It is a bad fit for anything that changes every frame, like a live health bar (that belongs in a normal script, as you will see in the next section).

3. Worked example: turning a hard-coded enemy into a data asset

Here is a typical beginner setup: one script per enemy type, numbers hard-coded directly into each one.

using UnityEngine;

public class Goblin : MonoBehaviour
{
    public int maxHealth = 20;
    public int damage = 5;
    public float moveSpeed = 2f;
    public int goldReward = 10;
}

public class Orc : MonoBehaviour
{
    public int maxHealth = 60;
    public int damage = 12;
    public float moveSpeed = 1.2f;
    public int goldReward = 30;
}

Every new enemy type means a new script that looks almost exactly like the last one. Changing the goblin's health means opening Goblin.cs, editing a number, and recompiling. Let us fix this with a ScriptableObject that holds the data, so we only need one enemy script ever.

using UnityEngine;

[CreateAssetMenu(fileName = "NewEnemyData", menuName = "Data/Enemy")]
public class EnemyData : ScriptableObject
{
    public string enemyName;
    public int maxHealth;
    public int damage;
    public float moveSpeed;
    public int goldReward;
}

With this script saved in the project, a designer right-clicks in the Project window, picks Create > Data > Enemy, and gets a new asset file. They do this twice, name the assets Goblin and Orc, and fill in the fields in the Inspector — no code touched at all.

Hard-coded: one script per enemy type --------------------------------------- Goblin.cs Orc.cs maxHealth = 20 maxHealth = 60 damage = 5 damage = 12 change a number --> edit the script --> recompile --> rebuild Data-driven: one script, many data assets -------------------------------------------- Enemy.cs (reads whatever "data" points at) | | data v +----------+-----------+ | | Goblin.asset Orc.asset maxHealth = 20 maxHealth = 60 damage = 5 damage = 12 change a number --> edit the asset in the Inspector --> done, no recompile

Using the data asset at runtime

Now write one Enemy script that any enemy GameObject can use. It holds a reference to an EnemyData asset and reads from it.

using UnityEngine;

public class Enemy : MonoBehaviour
{
    public EnemyData data;          // shared asset reference, read-only
    private int currentHealth;      // per-instance state, lives HERE, not in the asset

    private void Awake()
    {
        currentHealth = data.maxHealth;
    }

    public void TakeDamage(int amount)
    {
        currentHealth -= amount;
        if (currentHealth <= 0)
        {
            Die();
        }
    }

    private void Die()
    {
        Debug.Log(data.enemyName + " died. Reward: " + data.goldReward + " gold.");
        Destroy(gameObject);
    }
}

Drop the Enemy script onto a GameObject, drag the Goblin asset into its data slot, and you have a goblin. Drag Orc into a different GameObject's data slot instead, and that one is an orc — same script, different data. Trace what happens if this goblin's data has enemyName = "Goblin", maxHealth = 20, goldReward = 10, and something calls TakeDamage(25) once: currentHealth starts at 20, drops to -5, which is zero or less, so Die() runs and prints:

Goblin died. Reward: 10 gold.
Common mistake Do not store per-instance state (like currentHealth) inside the ScriptableObject itself. Every GameObject that shares the Goblin asset shares the same asset in memory — if you wrote data.currentHealth -= amount instead, damaging one goblin would damage every goblin using that asset, because they are all pointing at one object. Keep mutable, per-object state in the MonoBehaviour (like currentHealth above); keep only shared, unchanging content in the data asset.

4. External data files: JSON and CSV

ScriptableObjects are great, but they are still Unity assets, edited inside the Unity Editor. Sometimes your data needs to live outside Unity entirely: a huge spreadsheet of item stats a designer maintains in Google Sheets, or a list your live-service backend sends down after launch. For that, games commonly use two plain text formats: JSON and CSV.

JSON (JavaScript Object Notation) is a text format for structured data, written as key-value pairs inside curly braces, with square brackets for lists. CSV (Comma-Separated Values) is a plain text table: one row per line, values separated by commas — exactly what you get exporting a spreadsheet. In Unity, a JSON or CSV file gets imported as a TextAsset (an asset type that just holds raw text), which you can read in a script.

Here is a small item database as a JSON file, with a C# script that loads it. Note that the whole file is one JSON object with an items field holding the list — Unity's JSON reader cannot parse a bare list at the top level, so we wrap it.

{
  "items": [
    { "itemId": "potion_small", "displayName": "Small Potion", "baseValue": 10 },
    { "itemId": "sword_iron", "displayName": "Iron Sword", "baseValue": 120 }
  ]
}
using System.Collections.Generic;
using UnityEngine;

[System.Serializable]
public class ItemDefinitionData
{
    public string itemId;
    public string displayName;
    public int baseValue;
}

[System.Serializable]
public class ItemDatabase
{
    public List<ItemDefinitionData> items;
}

public class ItemDatabaseLoader : MonoBehaviour
{
    public TextAsset itemsJsonFile;   // drag the .json file onto this slot in the Inspector

    private void Start()
    {
        ItemDatabase db = JsonUtility.FromJson<ItemDatabase>(itemsJsonFile.text);
        Debug.Log("Loaded " + db.items.Count + " items.");
    }
}
Loaded 2 items.

CSV needs a little manual parsing since Unity has no built-in CSV reader, but it is short: split each line on commas.

string line = "sword_iron,Iron Sword,120";
string[] fields = line.Split(',');

string itemId = fields[0];
string displayName = fields[1];
int baseValue = int.Parse(fields[2]);

Debug.Log(itemId + " costs " + baseValue);
sword_iron costs 120

5. ScriptableObject vs JSON/CSV: when to use each

Both are data-driven. The choice is about workflow, not which one is "better".

Tip Many shipped games use both together. A designer maintains balance numbers in a spreadsheet exported as CSV, and a build-time tool converts each row into a ScriptableObject asset automatically. That gets the spreadsheet workflow for editing and the fast, reference-friendly ScriptableObject format for runtime use.

6. What "save game" actually means

Everything so far — EnemyData, ItemDatabase — is content: it is the same for every player and does not change while the game runs (it ships as part of the game). Save data is different: it is the specific state of one player's current playthrough — their level, their gold, their inventory, where they are standing, which quests they finished. Content ships with the game; save data is created and changed while someone plays.

Saving means writing a snapshot of the current game state to a file so it can be restored later. There is one rule that matters more than any other: save plain values, never references to live objects. A GameObject, a MonoBehaviour, a Transform, a Sprite — all of these only exist while the game is running, inside memory Unity is currently managing. The moment the game closes, they are gone. A reference to one of them is meaningless the next time the game starts, because that exact object was never created yet. Instead, save the numbers and strings that describe the state: a position as three floats (x, y, z), an inventory item as a string ID and a count, not the item's GameObject or its script instance.

Converting a live, in-memory object into a storable form (like text) is called serialization. Turning it back into a usable object is deserialization. That is exactly what the next two sections build.

7. Serializing to JSON with JsonUtility

First, design a plain data class that holds exactly the values you want to save — nothing more. This kind of class, whose only job is holding data with no real behavior, is sometimes called a POCO (Plain Old C# Object).

using System.Collections.Generic;

[System.Serializable]
public class InventoryItemData
{
    public string itemId;
    public int count;
}

[System.Serializable]
public class SaveData
{
    public int saveVersion = 2;
    public string playerName;
    public int level;
    public int gold;
    public float posX;
    public float posY;
    public float posZ;
    public List<InventoryItemData> inventory = new List<InventoryItemData>();
}

Notice saveVersion at the top — section 9 explains exactly why it is there. Now gather live game values into this class. This is the moment you copy numbers out of live objects, never the objects themselves.

using System.Collections.Generic;
using UnityEngine;

public class SaveController : MonoBehaviour
{
    public PlayerStats playerStats;
    public Inventory inventory;

    public SaveData BuildSaveData()
    {
        SaveData data = new SaveData();
        data.playerName = playerStats.playerName;
        data.level = playerStats.level;
        data.gold = playerStats.gold;

        Vector3 pos = playerStats.transform.position;
        data.posX = pos.x;
        data.posY = pos.y;
        data.posZ = pos.z;

        data.inventory = new List<InventoryItemData>();
        foreach (InventorySlot slot in inventory.slots)
        {
            InventoryItemData itemData = new InventoryItemData();
            itemData.itemId = slot.item.itemId;   // save the ID string, not the item asset itself
            itemData.count = slot.count;
            data.inventory.Add(itemData);
        }

        return data;
    }
}

Unity's built-in JsonUtility converts a [System.Serializable] object to and from a JSON string. Here is the smallest possible round trip:

SaveData data = new SaveData();
data.playerName = "Nova";
data.level = 5;
data.gold = 120;

string json = JsonUtility.ToJson(data, true);   // true = pretty-print with indentation
Debug.Log(json);
{
    "saveVersion": 2,
    "playerName": "Nova",
    "level": 5,
    "gold": 120,
    "posX": 0,
    "posY": 0,
    "posZ": 0,
    "inventory": []
}

Trace it: every field we set (playerName, level, gold) shows its value, and every field we never touched (posX, posY, posZ, the empty inventory list) shows its default. That is just an ordinary C# object turned into text.

Tip JsonUtility is simple but limited: the class must be marked [System.Serializable], only public fields are included (properties with get/set are ignored, and so are private fields unless marked [SerializeField]), and it cannot serialize a Dictionary or a bare top-level array — which is why ItemDatabase in section 4 wrapped its list inside a field called items.

8. Writing and reading files: persistent storage

A JSON string only helps once it is on disk. Unity gives you Application.persistentDataPath: a folder guaranteed to be writable, whose exact location Unity picks correctly for whatever platform the game is running on (a different folder on Windows, macOS, and mobile — you never need to know or hardcode which one). Combine that with System.IO.File to write and read text.

SAVE ---- [live game state: PlayerStats, Inventory, Transform] | | copy plain values (not references) v [SaveData object] | | JsonUtility.ToJson() v [JSON text] | | File.WriteAllText(path, json) v [save.json file on disk, inside Application.persistentDataPath] LOAD (same steps, reversed) ---------------------------- [save.json file on disk] | | File.ReadAllText(path) v [JSON text] | | JsonUtility.FromJson<SaveData>(json) v [SaveData object] | | copy values back into live objects v [live game state restored]
using System;
using System.IO;
using UnityEngine;

public static class SaveManager
{
    private const int CURRENT_SAVE_VERSION = 2;

    private static string GetSavePath()
    {
        return Path.Combine(Application.persistentDataPath, "save.json");
    }

    public static void Save(SaveData data)
    {
        data.saveVersion = CURRENT_SAVE_VERSION;
        string json = JsonUtility.ToJson(data, true);
        File.WriteAllText(GetSavePath(), json);
        Debug.Log("Saved game to " + GetSavePath());
    }

    public static SaveData Load()
    {
        string path = GetSavePath();

        if (!File.Exists(path))
        {
            Debug.Log("No save file found. Starting a new game.");
            return new SaveData();
        }

        try
        {
            string json = File.ReadAllText(path);
            SaveData data = JsonUtility.FromJson<SaveData>(json);
            data = MigrateIfNeeded(data);
            return data;
        }
        catch (Exception e)
        {
            Debug.LogWarning("Save file was corrupted, starting fresh. " + e.Message);
            return new SaveData();
        }
    }

    private static SaveData MigrateIfNeeded(SaveData data)
    {
        if (data.saveVersion < CURRENT_SAVE_VERSION)
        {
            Debug.Log("Upgrading save from version " + data.saveVersion + " to " + CURRENT_SAVE_VERSION);
            data.saveVersion = CURRENT_SAVE_VERSION;
        }
        return data;
    }
}

Trace a first-ever run, before any save file exists: Load() checks File.Exists(path), finds nothing, and returns a brand-new SaveData:

No save file found. Starting a new game.

Call SaveManager.Save(data) and it writes the file, printing something like:

Saved game to /storage/emulated/0/Android/data/com.yourstudio.yourgame/files/save.json

(The exact path text differs by platform — that is exactly why we asked Application.persistentDataPath instead of typing a path ourselves.)

9. Versioning your save data

Here is the problem versioning solves. You ship version 1.0 of your game. Players save their progress — files sitting on their disks with whatever shape SaveData had at that moment. Three months later you patch the game and, say, split hp (an int from 0 to 100) into a new field healthPercent (a float from 0 to 1) because the new health system needs it that way. Every save file already on a player's disk still has the old shape. If you just delete hp and add healthPercent with nothing connecting them, every returning player's health silently resets, because healthPercent was never in their old file and gets its C# default.

The fix: add an int saveVersion field (you already saw it sitting in SaveData since section 7). Every time you save, stamp the current version number into it. Every time you load, check the version you read against the current version your code expects, and if it is older, run migration code — code whose only job is upgrading an old save's shape into the new one — before anything else touches the data.

[System.Serializable]
public class SaveData
{
    public int saveVersion = 2;

    // Deprecated: kept only so JsonUtility can still read old version-1 save files.
    public int hp;

    // New in version 2: health is stored as a percentage instead of a raw hit-point number.
    public float healthPercent = 1f;
}

private static SaveData MigrateIfNeeded(SaveData data)
{
    if (data.saveVersion == 1)
    {
        data.healthPercent = data.hp / 100f;
        data.saveVersion = 2;
    }
    return data;
}

Trace it for a returning player whose old file has {"saveVersion": 1, "hp": 40}: JsonUtility.FromJson fills in hp = 40 (the field still exists in the class, so it still matches by name), healthPercent stays at its default of 1, then MigrateIfNeeded sees saveVersion == 1, computes healthPercent = 40 / 100f = 0.4, and bumps saveVersion to 2. The player keeps their 40% health instead of it silently resetting to full or empty.

save.json on disk, saveVersion = 1 | v Load() parses the JSON --> SaveData object (saveVersion field = 1) | v MigrateIfNeeded(): saveVersion 1 --> run the v1-to-v2 fix-up --> saveVersion = 2 | v saveVersion 2 matches CURRENT_SAVE_VERSION --> stop here, data is safe to use (if the game later ships version 3, an old version-1 save just runs the v1-to-v2 step, then the v2-to-v3 step, one after another, so you never need to write a direct "v1 straight to v5" conversion)
Common mistake Deleting the old field (hp in the example above) in the same patch that adds the new one. The instant you remove hp from the class, JsonUtility has nothing to match the old file's "hp": 40 against, and that value is gone the moment the file is loaded — your migration code never even sees it. Keep an old field around (mark it deprecated with a comment) for at least one version after you add its replacement, write the migration that reads it, and only delete it once you are confident no meaningfully old saves remain.

10. Common save/load pitfalls

Common mistake Saving references. A field like public GameObject targetEnemy; or public Transform playerTransform; inside a save class looks tempting but cannot work. JsonUtility either serializes it as an empty placeholder or errors, and even if it "worked" somehow, that exact object will not exist the next time the game launches. Save an ID (a string or int that identifies what the reference pointed at) and look the real object up again after loading.
Common mistake Hardcoding a file path. Typing something like "C:/Users/Me/save.json" works on exactly one computer. It breaks instantly on macOS, mobile, consoles, or any other player's PC. Always build the path from Application.persistentDataPath, as SaveManager.GetSavePath() did in section 8 — Unity already knows the correct writable folder for whatever platform the build is running on.
Common mistake Not handling a corrupted save. A save can get corrupted: the game crashes mid-write, the device loses power, or a player edits the file by hand and breaks the JSON syntax. If you call JsonUtility.FromJson on broken text without a try/catch, it throws an exception and can crash your load screen. SaveManager.Load() in section 8 wraps the read in try/catch and falls back to a fresh SaveData instead of crashing. For extra safety in a shipped game, you can also write to a temporary file first and only replace the real save once the write finishes completely, so a crash mid-save cannot leave a half-written file behind.
Common mistake Trusting the save file for anything competitive. A local JSON save is plain text — any player can open it in a text editor and change "gold": 120 to "gold": 999999. For a purely single-player game, that is usually harmless; players who want to cheat their own single-player save are only affecting themselves. But if any part of the save feeds into something competitive or shared — a leaderboard, currency that can be traded, matchmaking rank — do not trust the client's copy. Recompute or verify that value on a server you control before it affects anyone but the player who edited it.

11. A note on cloud saves for live-service games

A live-service game (a game that keeps running and receiving updates after launch, usually tied to online player accounts) typically cannot rely on a save file sitting on one device — players expect their progress to follow them if they switch phones or play on a different PC. The common approach: upload the same JSON blob you already built for the local file to a backend service tied to the player's account (examples include PlayFab, Steam Cloud, or a studio's own server), and download it again when they sign in elsewhere.

This introduces a new problem local saves do not have: conflicts. If a player plays offline on two devices and both eventually reconnect, whose save wins? The simplest strategy, and a reasonable default for a beginner project, is last-write-wins: keep a timestamp with the save and let whichever upload is newest overwrite the rest. It is not perfect — a player could lose real progress made on the "losing" device — but more sophisticated merging (combining specific fields instead of picking one whole save) is a much bigger design problem saved for later. Whatever strategy you use, keep the same saveVersion field in the cloud copy too; migrations apply the same way whether the JSON came from local disk or from a server.

12. Glossary

13. Exercises

Exercise 1 Here are two hard-coded weapon scripts. Convert them into a data-driven design: write a WeaponData ScriptableObject class (with [CreateAssetMenu]) holding weaponName, damage, and attackSpeed, and write a single Weapon MonoBehaviour script that reads from a WeaponData reference and has an Attack() method that logs a line like "Sword hits for 10 damage."
using UnityEngine;

public class Sword : MonoBehaviour
{
    public string weaponName = "Sword";
    public int damage = 10;
    public float attackSpeed = 1.0f;
}

public class Bow : MonoBehaviour
{
    public string weaponName = "Bow";
    public int damage = 6;
    public float attackSpeed = 1.8f;
}
Show answer
using UnityEngine;

[CreateAssetMenu(fileName = "NewWeaponData", menuName = "Data/Weapon")]
public class WeaponData : ScriptableObject
{
    public string weaponName;
    public int damage;
    public float attackSpeed;
}
using UnityEngine;

public class Weapon : MonoBehaviour
{
    public WeaponData data;

    public void Attack()
    {
        Debug.Log(data.weaponName + " hits for " + data.damage + " damage.");
    }
}

To use it: right-click in the Project window, choose Create > Data > Weapon twice, name the two assets Sword and Bow, fill in each one's fields in the Inspector (10/1.0 for the sword, 6/1.8 for the bow), then drag the matching asset onto the data slot of any GameObject holding the Weapon script. One script, any number of weapons, zero recompiles to add a new one.

Exercise 2 This is the current, shipped version of SaveData (version 1). Add a highScore field and bump the version to 2. Write the MigrateIfNeeded logic so that a returning player with an old version-1 save (which never had a highScore) gets a starting highScore equal to level * 100, instead of it silently defaulting to 0.
[System.Serializable]
public class SaveData
{
    public int saveVersion = 1;
    public string playerName;
    public int level;
    public int gold;
}

private const int CURRENT_SAVE_VERSION = 1;

private static SaveData MigrateIfNeeded(SaveData data)
{
    return data;
}
Show answer
[System.Serializable]
public class SaveData
{
    public int saveVersion = 2;
    public string playerName;
    public int level;
    public int gold;
    public int highScore;
}

private const int CURRENT_SAVE_VERSION = 2;

private static SaveData MigrateIfNeeded(SaveData data)
{
    if (data.saveVersion == 1)
    {
        data.highScore = data.level * 100;
        data.saveVersion = 2;
    }
    return data;
}

Without the explicit migration line, highScore would still end up as 0 for every returning player, because JsonUtility fills a field that is missing from the old JSON with its C# default. That happens to look fine here (0 is a reasonable "never scored" value), but the exercise's point is that you, not the deserializer, decide what an old save's new field should become — sometimes 0 is right, sometimes (like here, giving credit for existing level progress) it is not.

Exercise 3 A shipped version-1 save stores health as public int hp; (a value from 0 to 100). For version 2, the new health system needs it as public float healthPercent; (a value from 0 to 1). Write the new SaveData class and the MigrateIfNeeded method so that a player with 40 old hit points ends up with the correct healthPercent after loading, and explain in one sentence what would go wrong if you deleted the hp field in the same patch.
// version 1 shape, already shipped and on players' disks:
[System.Serializable]
public class SaveData
{
    public int saveVersion = 1;
    public int hp;   // 0 to 100
}
Show answer
[System.Serializable]
public class SaveData
{
    public int saveVersion = 2;

    // Deprecated: kept only so JsonUtility can still read old version-1 save files.
    public int hp;

    public float healthPercent = 1f;
}

private static SaveData MigrateIfNeeded(SaveData data)
{
    if (data.saveVersion == 1)
    {
        data.healthPercent = data.hp / 100f;
        data.saveVersion = 2;
    }
    return data;
}

For a player with hp = 40: JsonUtility still fills hp from the old JSON because the field is still present in the class, MigrateIfNeeded sees saveVersion == 1 and computes healthPercent = 40 / 100f = 0.4, then sets saveVersion = 2. If hp had been deleted in the same patch instead of kept as deprecated, JsonUtility would have nothing in the class to match the old file's "hp": 40 against, that value would be silently dropped during loading, and the migration code would have no source value left to convert — every returning player's health would reset to the default instead of carrying over.

← Back to all chapters