This system saves settings, multiple save slots, and endings that stay unlocked across playthroughs. Used in Exire - The Fire Will Find You, a horror visual novel I programmed in Unity for Spooktober 2026.
Intro
In my prior games, I didn't include a save feature because they were too short. Exire, though, has multiple endings and about 3 hours of gameplay, so I needed to implement saving for it.
It is also expected by many visual novel players that they can save, make a choice, then load if needed. We wanted to make a high quality experience for the players and allow them to save the game.
Saving turned out to be more complicated than I expected. It was most than just saving the player's items, health, and location. We needed to save the dialogue progress, the music playing, flashlight status, options settings, the locations of each screen on the screen, etc.
I ended up splitting saving into three parts, because each kind of data had its own using purpose:
-
Settings: Volume should stay the same no matter which save you load. These use
PlayerPrefs. -
Save slots: Stores one playthrough's progress. Each slot is a
PlayerDataobject written to its own JSON file. - Unlocked endings: This stays across all playthroughs, even New Game. It get its own small JSON file.
This design is fairly complicated, so I included a simple version that can be copy-pasted here.
Architecture Overview
Assets/Scripts/
├── UI/
│ ├── Options.cs # PlayerPrefs (Option settings)
│ ├── SaveMenu.cs # pick a slot, save
│ └── LoadMenu.cs # pick a slot, load
├── Data/
│ ├── PlayerDataManager.cs # singleton that saves, loads, and restores
│ ├── PlayerData.cs # the root save object
│ ├── EffectsSaveData.cs # filters, flashlight, music, looping SFX
│ └── EndingsUnlocked.cs # endings.json is shared across saves
└── Dialogue/
└── DialogueSaveData.cs # dialogue progress, choices, room phases
On disk, it looks like this:
persistentDataPath/
├── playerData_file0.json ... playerData_file9.json
└── endings.json
(+ PlayerPrefs, stored wherever the platform keeps them)
The flow for a save:
SaveMenu → PlayerDataManager.SaveGame(slot) → collect state from the managers → JsonUtility.ToJson → playerData_file{slot}.json
Type 1: PlayerPrefs for Settings
PlayerPrefs is Unity's built-in key/value store. It's not a good fit for game progress, but it works well for player preferences like options settings. The options class is only 15 lines:
public class Options
{
public static float MasterVolume = PlayerPrefs.GetFloat("MasterVolume", 1f);
public static float BGMVolume = PlayerPrefs.GetFloat("BGMVolume", .15f);
public static float SFXVolume = PlayerPrefs.GetFloat("SFXVolume", 1f);
public static void Save()
{
PlayerPrefs.SetFloat("MasterVolume", MasterVolume);
PlayerPrefs.SetFloat("BGMVolume", BGMVolume);
PlayerPrefs.SetFloat("SFXVolume", SFXVolume);
PlayerPrefs.Save();
}
}
The second argument to GetFloat is the default, so a fresh install of the game works. Background music defaults to .15 because at full volume it was drowning out the voice actors.
The main reason to keep these out of the save slots is that it allows the settings to stay the same across save files. It would be annoying to a player, or at least to me, to set the music volume in one save file, only for it to change in a different save file. So, the settings needed to be their own separate thing.
Type 2: SaveData in JSON Files
The data
Everything for one playthrough uses a single [Serializable] class:
[System.Serializable]
public class PlayerData
{
public string saveDate;
public bool isEmpty = true;
public string fileName;
public string sceneName;
public List<string> unlockedEndings = new();
public List<InventoryEntry> inventoryEntries = new();
public List<ItemVisibilityEntry> itemVisibility = new();
public DialogueSaveData dialogueSaveData = new();
public EffectsSaveData effectsSaveData = new();
}
It's split into smaller classes by system. DialogueSaveData tracks which dialogues have played, which choices were picked, what phase each character is in for each room, and whether the player saved mid-conversation. The line index tracks the conversation, so the player can resume on the exact line. EffectsSaveData stores the placements and sounds of the scene:
[System.Serializable]
public class EffectsSaveData
{
public bool flashlightEnabled;
public byte blackoutAlpha;
public bool dustEnabled;
public bool insanityFilterEnabled;
public bool noirEnabled;
public bool sepiaEnabled;
public bool staticEnabled;
public bool lexiFilterEnabled;
public SfxName loopingSfx = SfxName.None;
public MusicName music = MusicName.None;
public float musicVolume = 1f;
}
Music and sound effects are saved as enums, not AudioClips. You can't put an asset reference in a JSON file, so SfxDatabase and MusicDatabase map enums. Inventory items work the same way with an ItemName enum and an ItemDatabase.
The PlayerDataManager
PlayerDataManager is the one script the rest of the game communicates with for saving. It's a prefab placed in the main menu scene, and it uses the usual singleton pattern so there's exactly one, and it stays between scenes:
public class PlayerDataManager : MonoBehaviour
{
public static PlayerDataManager Instance { get; private set; }
public ItemDatabase itemDatabase;
public SfxDatabase sfxDatabase;
public MusicDatabase musicDatabase;
public GameObject inventoryItemPrefab;
private readonly List<InventoryEntry> inventoryEntries = new();
private readonly Dictionary<ItemName, bool> itemVisibility = new();
private void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
InitializeSaveFiles();
}
else
{
Destroy(gameObject);
}
}
}
The else branch is important. Going back to the main menu loads that scene again, which creates a second copy of the prefab. That copy sees an Instance already exists and destroys itself.
The databases are ScriptableObjects assigned in the Inspector. They're the enum asset lookups mentioned above, which let the manager turn a saved MusicName or ItemName back into a real asset on load.
The manager holds some state during the game, not just when the player saves. When the player picks up an item, the item reports it to the manager:
// PickupItem.cs, after the item is moved into an inventory slot
PlayerDataManager.Instance?.RegisterCollectedItem(itemName, slot.SlotIndex);
And when a scene loads, each item checks with the manager so you don't see an item in the room that's already in your inventory:
// PickupItem.Start()
if (!IsPickedUp && PlayerDataManager.Instance.HasCollectedItem(itemName))
gameObject.SetActive(false);
Because the manager stays between scenes, this works for normal play too, not only after loading a save. Walk out of a room and back in, and the item the player took is still gone.
Here's everything the rest of the game calls:
-
SaveGame(slot)/LoadGame(slot): called by the Save and Load menus. -
ReadSaveData(slot)/FileExists(slot): used by the menus to label each slot. -
RegisterCollectedItem,HasCollectedItem,SetItemVisibility,TryGetItemVisibility: called by items during play. -
ResetForNewGame(): called by the main menu's New Game button. It clears the inventory, dialogue state, and filters.
Saving
PlayerDataManager is a DontDestroyOnLoad singleton. On save, it gets the current state of each system and writes the result:
public void SaveGame(int fileIndex)
{
var playerData = new PlayerData
{
saveDate = System.DateTime.Now.ToString("yyyy-MM-dd HH:mm"),
fileName = $"File {fileIndex + 1}",
isEmpty = false,
sceneName = SceneManager.GetActiveScene().name,
unlockedEndings = EndingsUnlocked.GetUnlockedIds(),
inventoryEntries = new List<InventoryEntry>(inventoryEntries),
itemVisibility = ToVisibilityEntries(itemVisibility)
};
// ...dialogue, flashlight, filters, and audio state copied in here...
var json = JsonUtility.ToJson(playerData);
var path = Application.persistentDataPath + $"/playerData_file{fileIndex}.json";
File.WriteAllText(path, json);
}
The ToVisibilityEntries call is there because JsonUtility can't serialize a Dictionary. At runtime, item visibility is a Dictionary<ItemName, bool>, which is easy to look up. At save time, it gets converted into a List<ItemVisibilityEntry>, and on load it's rebuilt.
The dictionary also only stores items with visibility that was changed by a dialogue action. Anything not in the dictionary keeps the visibility that was set in the scene. My first version defaulted missing items to hidden, and every item in the game disappeared on load.
Empty slots
On startup, the manager creates any slot files that don't exist yet with isEmpty = true. This way the Save and Load menus can always read every slot and show either File 3 : Bedroom : 2026-10-12 21:40 or File 3 : Empty. The Load menu disables the button for empty slots so the player can't attempt to load them.
Loading and all its complications
Writing JSON was easier than loading it. Getting the game back into the right state is more difficult, because the order of the load function matters. A state needs to be in place before the scene loads, so objects read it in their Start(). However, some parts of the load state need a UI that only exists after the scene loads. So loading is a coroutine:
private IEnumerator LoadGameCoroutine(PlayerData playerData)
{
playerInput.DeactivateInput();
MusicManager.Instance.StopMusic();
yield return FilterManager.Instance.FadeToBlack();
// 1. Before the scene loads. Data that scene objects read on start.
RestoreItemVisibility(playerData);
RestoreInventoryEntries(playerData);
DialogueHelper.Instance.dialogueState = playerData.dialogueSaveData ?? new DialogueSaveData();
var asyncLoad = SceneManager.LoadSceneAsync(playerData.sceneName);
yield return new WaitUntil(() => asyncLoad.isDone);
yield return null;
// 2. After the scene loads. Data that needs the scene UI.
RebuildInventory();
ResumeDialogueIfNeeded();
// 3. Load filters and music before game resumes.
RestoreEffects(playerData.effectsSaveData;
yield return FilterManager.Instance.FadeFromBlack();
playerInput.ActivateInput();
}
I condensed the helper calls in this example. In the actual file they're written out inline.
Some details:
- Input is disabled for the whole load, so the player can't click into a half-loaded scene.
- Filters switch instantly behind the black screen. They're all set while the screen is covered, so the player never sees the filter flash on after the game restarts.
- Music also starts before the game fades in, so it plays as the scene loads.
Type 3: Ending Save Data Stays Intact
The game has multiple endings, and the main menu shows which ones you've found. I wanted to make it so the player doesn't lose their ending clears if they were to delete a save file. So EndingsUnlocked is a static class with its own file:
public static class EndingsUnlocked
{
private static readonly HashSet<string> unlockedIds = new();
private static string FilePath => Application.persistentDataPath + "/endings.json";
// JsonUtility can't serialize a bare list, so it needs a wrapper.
[System.Serializable]
private class EndingsSaveData
{
public List<string> unlockedIds = new();
}
public static void Unlock(string endingId)
{
if (string.IsNullOrEmpty(endingId))
return;
if (unlockedIds.Add(endingId))
SaveToFile();
}
// ...
}
HashSet.Add returns false if the ending was already unlocked, so the file is only rewritten when something actually changes. The main menu calls LoadFromFile() on start, and ResetForNewGame() leaves endings alone.
Endings are also copied into each save slot. When you load a slot, they're merged in rather than replaced:
// Merge rather than replace, so loading an older save doesn't wipe endings earned since.
foreach (var endingId in playerData.unlockedEndings)
EndingsUnlocked.Unlock(endingId);
A Minimal Version You Can Start From
All of the above is tied to this game's systems, so here's a stripped-down version you can paste into your own project. Save it as SaveManager.cs, put it on a GameObject in your first scene, and add fields to SaveData as you need them.
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEngine.SceneManagement;
[System.Serializable]
public class SaveData
{
public string saveDate;
public string sceneName;
public List<string> collectedItems = new();
}
public class SaveManager : MonoBehaviour
{
public static SaveManager Instance { get; private set; }
// The live game state. Gameplay code reads and writes this during the game.
public SaveData Current { get; private set; } = new();
private void Awake()
{
if (Instance != null)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
private static string GetPath(int slot) =>
Path.Combine(Application.persistentDataPath, $"save{slot}.json");
public bool HasSave(int slot) => File.Exists(GetPath(slot));
public void NewGame() => Current = new SaveData();
public void Save(int slot)
{
Current.saveDate = System.DateTime.Now.ToString("yyyy-MM-dd HH:mm");
Current.sceneName = SceneManager.GetActiveScene().name;
File.WriteAllText(GetPath(slot), JsonUtility.ToJson(Current, true));
}
public void Load(int slot)
{
if (!HasSave(slot))
return;
// Restore the data before loading the scene, so objects can read it in Start().
Current = JsonUtility.FromJson<SaveData>(File.ReadAllText(GetPath(slot)));
SceneManager.LoadScene(Current.sceneName);
}
}
Using it from gameplay code looks like this:
// When the player picks up an item
SaveManager.Instance.Current.collectedItems.Add("RustyKey");
// In the item's Start(), so it stays gone after loading or re-entering the room
if (SaveManager.Instance.Current.collectedItems.Contains("RustyKey"))
gameObject.SetActive(false);
The second argument to ToJson pretty-prints the file, which makes it easy to open and debug. From here, the rest of this article covers where to expand on it:
- Settings go in
PlayerPrefs, not inSaveData. - Dictionaries need to be converted to a list of key/value entries before
JsonUtilitywill save them. - Assets like music or sprites get saved as an enum or ID, then looked up again on load.
- Effects, music, and UI turn
Loadinto a coroutine with a set order. - Anything that should outlast a playthrough, like endings or achievements, gets its own file.
What I'd Do Differently
Let each system save itself.
Right now SaveGame and LoadGameCoroutine get data from every resource manager. Each time I added an effect, I had to edit three places, EffectsSaveData, the save method, and the load method. Next time I'd have each system implement a small interface with something like CaptureState() and RestoreState(), so PlayerDataManager can just loop over them.
Set up the enum + database pattern on day one.
Saving MusicName.Attic instead of an AudioClip turned out a clean way to save audio, but I added it once saving already existed. Starting with it would have saved some reworking.
Keep the slot count in one place.
PlayerDataManager creates 10 slot files, but the Save and Load menus each have their own numberOfSaveFiles set to 5. It's harmless now, but it's the kind of thing that turns into a bug later.
Enable deleting a save file
The game has no way to delete a save. They can save over the time, but it seems like the right thing to do to let the player delete a save file. In a future game, I would enable that.
Closing
I didn't realize how complicated saving could be, especially if there are a lot of variables on the scene. I also didn't find many tutorials on this. Most of the tutorials just said to use PlayerPrefs, which isn't robust enough for what I wanted to do, and it makes it too easy for a player to edit their save file and cheat.
I am thinking of writing more Unity tutorials like this. Let me know if you found this helpful or would like to see me.
Top comments (0)