File I/O and Data Persistence
Games need to remember player progress, settings, and achievements. File I/O (Input/Output) allows your games to save data to files and load it back later, creating persistent experiences that players can return to. In C#, you can read and write files, serialize complex data structures, and create save systems that work across different platforms.
In this chapter, you'll learn how to save and load game data using C# file operations. By the end, you'll be able to create save systems, store player progress, and persist game state between sessions.
What You'll Learn
- File Reading and Writing - Basic file operations in C#
- Data Serialization - Converting objects to and from formats like JSON
- Unity PlayerPrefs - Simple key-value storage for Unity games
- Save System Architecture - Designing robust save/load systems
- Error Handling - Safely handling file operations
- Best Practices - Creating reliable data persistence
Prerequisites
- Completed Error Handling: Try-Catch and Debugging
- Understanding of classes, objects, and collections
- Basic familiarity with Unity (optional, but helpful)
Understanding File I/O
File I/O allows your programs to read data from files and write data to files. This enables your games to:
- Save player progress - Store levels completed, scores, and achievements
- Remember settings - Save graphics options, audio levels, and controls
- Persist game state - Keep inventory, character stats, and world state
- Create backups - Store multiple save files for different playthroughs
Real-World Analogy: Think of file I/O like a notebook. You can write information in it (saving), close it, and later read what you wrote (loading). The notebook persists even after you close your program, just like game saves persist between sessions.
Reading and Writing Text Files
The simplest file operations work with text files. C# provides several classes for reading and writing text.
Writing to a File
using System.IO;
// Write text to a file
string filePath = "savegame.txt";
string content = "Player Level: 5\nScore: 1000\nHealth: 100";
File.WriteAllText(filePath, content);
Key Points:
File.WriteAllText()writes all text at once- Overwrites existing files
- Creates file if it doesn't exist
- Simple but not efficient for large files
Reading from a File
using System.IO;
// Read text from a file
string filePath = "savegame.txt";
if (File.Exists(filePath))
{
string content = File.ReadAllText(filePath);
Console.WriteLine(content);
}
else
{
Console.WriteLine("File not found!");
}
Key Points:
File.ReadAllText()reads entire file- Always check if file exists first
- Returns empty string if file is empty
- Simple but loads entire file into memory
Appending to a File
using System.IO;
// Append text to existing file
string filePath = "log.txt";
string newEntry = "Game started at " + DateTime.Now + "\n";
File.AppendAllText(filePath, newEntry);
Key Points:
File.AppendAllText()adds to end of file- Creates file if it doesn't exist
- Useful for logs and history
- Doesn't overwrite existing content
Reading and Writing Lines
For structured data, reading and writing line-by-line is more efficient.
Writing Lines
using System.IO;
// Write multiple lines
string[] lines = {
"Player Name: Alice",
"Level: 10",
"Score: 5000",
"Health: 75"
};
File.WriteAllLines("playerdata.txt", lines);
Reading Lines
using System.IO;
// Read all lines
string[] lines = File.ReadAllLines("playerdata.txt");
foreach (string line in lines)
{
Console.WriteLine(line);
}
Advantages:
- Process data line by line
- More memory efficient for large files
- Easier to parse structured data
- Better for logs and configuration files
StreamReader and StreamWriter
For more control over file operations, use StreamReader and StreamWriter.
Writing with StreamWriter
using System.IO;
string filePath = "savegame.txt";
using (StreamWriter writer = new StreamWriter(filePath))
{
writer.WriteLine("Player Name: Bob");
writer.WriteLine("Level: 15");
writer.WriteLine("Score: 7500");
writer.WriteLine("Health: 100");
}
// File is automatically closed when 'using' block ends
Key Points:
usingstatement ensures file is closedWriteLine()adds newline automatically- More control over writing process
- Can write incrementally
Reading with StreamReader
using System.IO;
string filePath = "savegame.txt";
if (File.Exists(filePath))
{
using (StreamReader reader = new StreamReader(filePath))
{
string line;
while ((line = reader.ReadLine()) != null)
{
Console.WriteLine(line);
}
}
}
Key Points:
ReadLine()reads one line at a time- Returns
nullwhen end of file reached - Memory efficient for large files
- More control over reading process
Data Serialization
Complex data structures need to be converted to formats that can be stored in files. This process is called serialization.
JSON Serialization
JSON (JavaScript Object Notation) is a popular format for storing structured data. C# can serialize objects to JSON and deserialize JSON back to objects.
using System;
using System.IO;
using Newtonsoft.Json; // Requires Newtonsoft.Json NuGet package
[Serializable]
public class PlayerData
{
public string playerName;
public int level;
public int score;
public float health;
public List<string> inventory;
}
// Save to JSON
PlayerData data = new PlayerData
{
playerName = "GamineAI Team",
level = 10,
score = 5000,
health = 75.5f,
inventory = new List<string> { "Sword", "Shield", "Potion" }
};
string json = JsonConvert.SerializeObject(data, Formatting.Indented);
File.WriteAllText("playerdata.json", json);
// Load from JSON
string jsonContent = File.ReadAllText("playerdata.json");
PlayerData loadedData = JsonConvert.DeserializeObject<PlayerData>(jsonContent);
Key Points:
- JSON is human-readable and widely supported
[Serializable]attribute marks classes for serializationFormatting.Indentedmakes JSON readable- Works with complex nested objects
Unity JSON Utility
Unity provides its own JSON serialization that works with Unity types:
using UnityEngine;
using System.IO;
[System.Serializable]
public class GameSaveData
{
public string playerName;
public int level;
public Vector3 playerPosition;
public List<Item> inventory;
}
// Save
GameSaveData saveData = new GameSaveData
{
playerName = "Player1",
level = 5,
playerPosition = new Vector3(10, 0, 5),
inventory = new List<Item>()
};
string json = JsonUtility.ToJson(saveData, true);
string filePath = Application.persistentDataPath + "/savegame.json";
File.WriteAllText(filePath, json);
// Load
string jsonContent = File.ReadAllText(filePath);
GameSaveData loadedData = JsonUtility.FromJson<GameSaveData>(jsonContent);
Key Points:
JsonUtilityis Unity's built-in JSON serializer- Works with Unity types like
Vector3,Color, etc. Application.persistentDataPathis platform-specific save location- Simpler than external libraries
Unity PlayerPrefs
Unity provides PlayerPrefs for simple key-value storage. It's perfect for settings and small amounts of data.
Saving with PlayerPrefs
using UnityEngine;
// Save different data types
PlayerPrefs.SetString("PlayerName", "GamineAI Team");
PlayerPrefs.SetInt("Level", 10);
PlayerPrefs.SetFloat("Volume", 0.75f);
PlayerPrefs.SetInt("HighScore", 5000);
// Save to disk
PlayerPrefs.Save();
Supported Types:
SetString()- Text dataSetInt()- Integer numbersSetFloat()- Decimal numbersSave()- Writes to disk (call after setting values)
Loading with PlayerPrefs
using UnityEngine;
// Load with default values
string playerName = PlayerPrefs.GetString("PlayerName", "Unknown");
int level = PlayerPrefs.GetInt("Level", 1);
float volume = PlayerPrefs.GetFloat("Volume", 1.0f);
int highScore = PlayerPrefs.GetInt("HighScore", 0);
// Check if key exists
if (PlayerPrefs.HasKey("PlayerName"))
{
string name = PlayerPrefs.GetString("PlayerName");
}
Key Points:
GetString(),GetInt(),GetFloat()retrieve values- Second parameter is default value if key doesn't exist
HasKey()checks if value exists- Platform-specific storage location
PlayerPrefs Limitations
// PlayerPrefs is NOT suitable for:
// - Complex objects (use JSON instead)
// - Large amounts of data (use files instead)
// - Binary data (use File.WriteAllBytes instead)
// - Multiple save files (use custom file system instead)
// Good uses for PlayerPrefs:
// - Settings (volume, graphics quality)
// - Simple flags (tutorial completed, first launch)
// - Small preferences (language, controls)
Unity Game Development Examples
Example 1: Complete Save System
using UnityEngine;
using System.IO;
using System;
[Serializable]
public class SaveData
{
public string playerName;
public int level;
public int score;
public float playTime;
public Vector3 playerPosition;
public List<string> unlockedLevels;
}
public class SaveManager : MonoBehaviour
{
private string saveFilePath;
void Start()
{
saveFilePath = Application.persistentDataPath + "/savegame.json";
}
public void SaveGame(SaveData data)
{
try
{
string json = JsonUtility.ToJson(data, true);
File.WriteAllText(saveFilePath, json);
Debug.Log("Game saved successfully!");
}
catch (Exception ex)
{
Debug.LogError($"Failed to save game: {ex.Message}");
}
}
public SaveData LoadGame()
{
try
{
if (File.Exists(saveFilePath))
{
string json = File.ReadAllText(saveFilePath);
SaveData data = JsonUtility.FromJson<SaveData>(json);
Debug.Log("Game loaded successfully!");
return data;
}
else
{
Debug.Log("No save file found. Starting new game.");
return CreateNewSave();
}
}
catch (Exception ex)
{
Debug.LogError($"Failed to load game: {ex.Message}");
return CreateNewSave();
}
}
private SaveData CreateNewSave()
{
return new SaveData
{
playerName = "Player",
level = 1,
score = 0,
playTime = 0,
playerPosition = Vector3.zero,
unlockedLevels = new List<string> { "Level1" }
};
}
public bool SaveExists()
{
return File.Exists(saveFilePath);
}
public void DeleteSave()
{
if (File.Exists(saveFilePath))
{
File.Delete(saveFilePath);
Debug.Log("Save file deleted.");
}
}
}
Example 2: Settings Manager
using UnityEngine;
public class SettingsManager : MonoBehaviour
{
private const string KEY_VOLUME = "MasterVolume";
private const string KEY_QUALITY = "GraphicsQuality";
private const string KEY_FULLSCREEN = "Fullscreen";
private const string KEY_LANGUAGE = "Language";
public void SaveSettings(float volume, int quality, bool fullscreen, string language)
{
PlayerPrefs.SetFloat(KEY_VOLUME, volume);
PlayerPrefs.SetInt(KEY_QUALITY, quality);
PlayerPrefs.SetInt(KEY_FULLSCREEN, fullscreen ? 1 : 0);
PlayerPrefs.SetString(KEY_LANGUAGE, language);
PlayerPrefs.Save();
}
public Settings LoadSettings()
{
return new Settings
{
volume = PlayerPrefs.GetFloat(KEY_VOLUME, 1.0f),
quality = PlayerPrefs.GetInt(KEY_QUALITY, 2),
fullscreen = PlayerPrefs.GetInt(KEY_FULLSCREEN, 1) == 1,
language = PlayerPrefs.GetString(KEY_LANGUAGE, "English")
};
}
public void ResetToDefaults()
{
PlayerPrefs.DeleteKey(KEY_VOLUME);
PlayerPrefs.DeleteKey(KEY_QUALITY);
PlayerPrefs.DeleteKey(KEY_FULLSCREEN);
PlayerPrefs.DeleteKey(KEY_LANGUAGE);
PlayerPrefs.Save();
}
}
[Serializable]
public class Settings
{
public float volume;
public int quality;
public bool fullscreen;
public string language;
}
Example 3: High Score System
using UnityEngine;
using System.Collections.Generic;
using System.Linq;
using System.IO;
[Serializable]
public class HighScoreEntry
{
public string playerName;
public int score;
public string date;
}
[Serializable]
public class HighScoreData
{
public List<HighScoreEntry> scores = new List<HighScoreEntry>();
}
public class HighScoreManager : MonoBehaviour
{
private string filePath;
private HighScoreData highScoreData;
void Start()
{
filePath = Application.persistentDataPath + "/highscores.json";
LoadHighScores();
}
public void AddScore(string playerName, int score)
{
HighScoreEntry entry = new HighScoreEntry
{
playerName = playerName,
score = score,
date = System.DateTime.Now.ToString("yyyy-MM-dd")
};
highScoreData.scores.Add(entry);
highScoreData.scores = highScoreData.scores
.OrderByDescending(s => s.score)
.Take(10)
.ToList();
SaveHighScores();
}
public List<HighScoreEntry> GetTopScores(int count)
{
return highScoreData.scores
.OrderByDescending(s => s.score)
.Take(count)
.ToList();
}
private void SaveHighScores()
{
try
{
string json = JsonUtility.ToJson(highScoreData, true);
File.WriteAllText(filePath, json);
}
catch (System.Exception ex)
{
Debug.LogError($"Failed to save high scores: {ex.Message}");
}
}
private void LoadHighScores()
{
try
{
if (File.Exists(filePath))
{
string json = File.ReadAllText(filePath);
highScoreData = JsonUtility.FromJson<HighScoreData>(json);
}
else
{
highScoreData = new HighScoreData();
}
}
catch (System.Exception ex)
{
Debug.LogError($"Failed to load high scores: {ex.Message}");
highScoreData = new HighScoreData();
}
}
}
Best Practices
1. Always Use Try-Catch
// Good: Handle errors gracefully
try
{
string content = File.ReadAllText(filePath);
// Process content
}
catch (FileNotFoundException ex)
{
Debug.LogWarning("Save file not found. Starting new game.");
// Create new save
}
catch (Exception ex)
{
Debug.LogError($"Error reading file: {ex.Message}");
// Handle error
}
// Bad: Let errors crash the game
string content = File.ReadAllText(filePath); // Crashes if file doesn't exist!
2. Use Platform-Specific Paths
// Good: Use Application.persistentDataPath
string savePath = Application.persistentDataPath + "/savegame.json";
// Bad: Hard-coded paths
string savePath = "C:/Games/MyGame/savegame.json"; // Only works on Windows!
3. Validate Data After Loading
// Good: Validate loaded data
SaveData data = LoadGame();
if (data == null || data.level < 1)
{
Debug.LogWarning("Invalid save data. Creating new save.");
data = CreateNewSave();
}
// Bad: Assume data is valid
SaveData data = LoadGame();
int level = data.level; // Might be null or invalid!
4. Create Backup Saves
public void SaveGameWithBackup(SaveData data)
{
string mainPath = Application.persistentDataPath + "/savegame.json";
string backupPath = Application.persistentDataPath + "/savegame_backup.json";
// Create backup of existing save
if (File.Exists(mainPath))
{
File.Copy(mainPath, backupPath, true);
}
// Save new data
try
{
string json = JsonUtility.ToJson(data, true);
File.WriteAllText(mainPath, json);
}
catch (Exception ex)
{
// Restore from backup if save fails
if (File.Exists(backupPath))
{
File.Copy(backupPath, mainPath, true);
}
throw;
}
}
5. Use Appropriate Storage Methods
// Use PlayerPrefs for:
// - Simple settings (volume, quality)
// - Small flags (tutorial completed)
// - User preferences
// Use JSON files for:
// - Complex game state
// - Player progress
// - Multiple save slots
// - Custom data structures
// Use binary files for:
// - Large amounts of data
// - Performance-critical saves
// - Encrypted data
Common Mistakes to Avoid
Mistake 1: Not Checking File Existence
// Wrong: Assumes file exists
string content = File.ReadAllText("savegame.txt");
// Correct: Check first
if (File.Exists("savegame.txt"))
{
string content = File.ReadAllText("savegame.txt");
}
Mistake 2: Not Handling Exceptions
// Wrong: No error handling
string json = File.ReadAllText(filePath);
SaveData data = JsonUtility.FromJson<SaveData>(json);
// Correct: Handle errors
try
{
string json = File.ReadAllText(filePath);
SaveData data = JsonUtility.FromJson<SaveData>(json);
}
catch (Exception ex)
{
Debug.LogError($"Failed to load: {ex.Message}");
// Create default data
}
Mistake 3: Using Wrong Paths
// Wrong: Hard-coded path
string path = "C:/Games/save.json"; // Only works on Windows!
// Correct: Platform-specific path
string path = Application.persistentDataPath + "/save.json";
Mistake 4: Not Saving PlayerPrefs
// Wrong: Forgets to save
PlayerPrefs.SetInt("Score", 100);
// Data not written to disk!
// Correct: Always save
PlayerPrefs.SetInt("Score", 100);
PlayerPrefs.Save(); // Writes to disk
Practical Exercise
Create a save system that:
- Saves player name, level, score, and inventory
- Loads saved data or creates new game
- Handles errors gracefully
- Validates loaded data
- Supports multiple save slots
Solution:
using UnityEngine;
using System.IO;
using System;
[Serializable]
public class PlayerSaveData
{
public string playerName;
public int level;
public int score;
public List<string> inventory;
}
public class GameSaveSystem : MonoBehaviour
{
public void SaveGame(int slot, PlayerSaveData data)
{
string filePath = GetSaveFilePath(slot);
try
{
string json = JsonUtility.ToJson(data, true);
File.WriteAllText(filePath, json);
Debug.Log($"Game saved to slot {slot}");
}
catch (Exception ex)
{
Debug.LogError($"Failed to save: {ex.Message}");
}
}
public PlayerSaveData LoadGame(int slot)
{
string filePath = GetSaveFilePath(slot);
try
{
if (File.Exists(filePath))
{
string json = File.ReadAllText(filePath);
PlayerSaveData data = JsonUtility.FromJson<PlayerSaveData>(json);
// Validate data
if (data == null || data.level < 1)
{
return CreateNewGame();
}
return data;
}
else
{
return CreateNewGame();
}
}
catch (Exception ex)
{
Debug.LogError($"Failed to load: {ex.Message}");
return CreateNewGame();
}
}
public bool SaveExists(int slot)
{
return File.Exists(GetSaveFilePath(slot));
}
public void DeleteSave(int slot)
{
string filePath = GetSaveFilePath(slot);
if (File.Exists(filePath))
{
File.Delete(filePath);
}
}
private string GetSaveFilePath(int slot)
{
return Application.persistentDataPath + $"/save_slot_{slot}.json";
}
private PlayerSaveData CreateNewGame()
{
return new PlayerSaveData
{
playerName = "Player",
level = 1,
score = 0,
inventory = new List<string>()
};
}
}
Next Steps
Now that you understand file I/O and data persistence, you're ready to learn about advanced C# features:
- Advanced C# Features - LINQ, async/await, and more
- Performance Optimization - Making your code run faster
- Design Patterns - Professional code organization
Move on to Advanced C# Features: LINQ and Async/Await to learn about powerful C# language features.
Summary
- File I/O allows reading and writing data to files
- Text files are simple but limited for complex data
- JSON serialization converts objects to text format
- Unity PlayerPrefs is perfect for simple settings
- Always handle errors when working with files
- Use platform-specific paths for cross-platform compatibility
- Validate loaded data to ensure it's correct
- Create backup saves to prevent data loss
File I/O and data persistence are essential for creating games that remember player progress and preferences. By mastering file operations, serialization, and Unity's storage systems, you can create save systems that work reliably across different platforms. Practice implementing save/load functionality to become comfortable with data persistence in your game development projects.