9.2 - Reading and Writing Text Files
Text files are one of the most common and versatile file formats you'll work with in game development. They're human-readable, easy to edit, and perfect for storing configuration settings, level data, dialogue scripts, and more. In this section, we'll explore various techniques for reading and writing text files in C#.
Text File Basics
Text files store data as sequences of characters, typically organized into lines. They're versatile because:
- They're human-readable and can be edited with any text editor
- They work across all platforms
- They're easy to parse and generate
- They can store structured data in formats like CSV, JSON, or XML
However, text files also have limitations:
- They're not as compact as binary files
- They can be slower to process for large amounts of data
- They're not ideal for storing sensitive information (unless encrypted)
Reading Text Files
C# provides several methods for reading text files, each suited to different scenarios.
Reading an Entire File at Once
The simplest way to read a text file is to load its entire contents into memory at once:
// Read all text into a single string
string fileContents = File.ReadAllText("gameDialogue.txt");
Console.WriteLine(fileContents);
// Read all lines into a string array
string[] lines = File.ReadAllLines("highScores.txt");
foreach (string line in lines)
{
Console.WriteLine(line);
}
These methods are convenient for small files, but be cautious with large files as they load the entire content into memory at once.
Reading a File Line by Line
For larger files or when you want to process a file incrementally, reading line by line is more efficient:
// Using StreamReader to read line by line
using (StreamReader reader = new StreamReader("largeLogFile.txt"))
{
string line;
while ((line = reader.ReadLine()) != null)
{
Console.WriteLine(line);
// Process each line here
}
}
This approach uses less memory because it only loads one line at a time.
Reading with File Encoding
Text files can be encoded in different ways (UTF-8, ASCII, Unicode, etc.). By default, .NET uses UTF-8 encoding, but you can specify a different encoding if needed:
// Read a file with specific encoding
using (StreamReader reader = new StreamReader("localizedText.txt", Encoding.UTF8))
{
string content = reader.ReadToEnd();
Console.WriteLine(content);
}
To use different encodings, you'll need to include the System.Text
namespace:
using System.Text;
Writing Text Files
Just as with reading, C# offers several methods for writing text files.
Writing an Entire File at Once
The simplest way to write a text file is to create or overwrite it with new content:
// Write a single string to a file
string gameSettings = "Difficulty=Hard\nVolume=80\nFullscreen=true";
File.WriteAllText("settings.txt", gameSettings);
// Write an array of strings to a file (one line per string)
string[] highScores = {
"Player1,10000",
"Player2,8500",
"Player3,7200"
};
File.WriteAllLines("highScores.txt", highScores);
These methods are convenient for small amounts of data.
Writing a File Line by Line
For more control or when building a file incrementally, use StreamWriter
:
using (StreamWriter writer = new StreamWriter("gameLog.txt"))
{
writer.WriteLine("Game started at: " + DateTime.Now);
writer.WriteLine("Player name: PlayerOne");
writer.WriteLine("Level loaded: Forest");
// Write without a line break
writer.Write("Status: ");
writer.Write("Active");
}
Appending to an Existing File
Often, you'll want to add content to a file without overwriting its existing contents:
// Append a single line
File.AppendAllText("log.txt", "New event occurred at " + DateTime.Now + "\n");
// Append multiple lines
string[] newEntries = {
"Level 2 completed",
"New achievement unlocked: Speed Runner",
"Total score: 12500"
};
File.AppendAllLines("playerProgress.txt", newEntries);
// Using StreamWriter to append
using (StreamWriter writer = new StreamWriter("gameLog.txt", append: true))
{
writer.WriteLine("Game session ended at: " + DateTime.Now);
}
The append
parameter (set to true
) tells the StreamWriter
to add to the file rather than overwrite it.
Using the using
Statement
You may have noticed the using
statement in many of the examples above:
using (StreamReader reader = new StreamReader("file.txt"))
{
// Code that uses the reader
}
This pattern is crucial when working with files because:
- It ensures that the file is properly closed even if an exception occurs
- It releases system resources (like file handles) promptly
- It prevents file locking issues that could block other parts of your program
The using
statement automatically calls the Dispose()
method on the object when the block exits, which for file streams means closing the file.
Practical Example: Simple Game Log System
Let's create a simple logging system for a game that records player actions and game events:
public class GameLogger
{
private readonly string logFilePath;
private readonly bool appendToExisting;
public GameLogger(string filePath, bool append = true)
{
logFilePath = filePath;
appendToExisting = append;
// Create the directory if it doesn't exist
string directory = Path.GetDirectoryName(logFilePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
// Initialize the log file if we're not appending
if (!append && File.Exists(logFilePath))
{
File.Delete(logFilePath);
}
// Write the header
LogMessage("=== Game Session Started ===");
LogMessage($"Date/Time: {DateTime.Now}");
LogMessage("==============================");
}
public void LogMessage(string message)
{
try
{
// Format the log entry with timestamp
string logEntry = $"[{DateTime.Now:HH:mm:ss}] {message}";
// Write to the log file
using (StreamWriter writer = new StreamWriter(logFilePath, appendToExisting))
{
writer.WriteLine(logEntry);
}
// Also output to console for debugging
Console.WriteLine(logEntry);
}
catch (Exception ex)
{
// If we can't write to the log file, at least show the error
Console.WriteLine($"Error writing to log: {ex.Message}");
}
}
public void LogPlayerAction(string playerName, string action)
{
LogMessage($"PLAYER: {playerName} - {action}");
}
public void LogGameEvent(string eventType, string details)
{
LogMessage($"EVENT: {eventType} - {details}");
}
public void LogError(string errorMessage)
{
LogMessage($"ERROR: {errorMessage}");
}
public void EndSession()
{
LogMessage("==============================");
LogMessage("=== Game Session Ended ===");
LogMessage($"Date/Time: {DateTime.Now}");
LogMessage("==============================");
}
}
// Usage example
public class Game
{
private GameLogger logger;
public void Start()
{
// Initialize the logger
logger = new GameLogger("Logs/gameLog.txt");
// Log some game events
logger.LogGameEvent("GameStart", "New game started");
logger.LogPlayerAction("Hero", "Entered the Forest level");
// Simulate gameplay
logger.LogGameEvent("Combat", "Player encountered a goblin");
logger.LogPlayerAction("Hero", "Defeated goblin, gained 50 XP");
// Log an error
try
{
// Simulate an error
int result = 10 / 0;
}
catch (Exception ex)
{
logger.LogError($"Calculation error: {ex.Message}");
}
// End the session
logger.EndSession();
}
}
This example demonstrates:
- Creating a dedicated class for logging
- Ensuring the log directory exists
- Formatting log entries with timestamps
- Different log entry types for different kinds of information
- Error handling for the logging itself
- Session start and end markers
Reading and Writing CSV Files
Comma-Separated Values (CSV) is a common format for storing tabular data. It's simple, widely supported, and perfect for things like game data tables, inventory items, or enemy statistics.
Writing CSV Data
public class Item
{
public int Id { get; set; }
public string Name { get; set; }
public int Value { get; set; }
public string Type { get; set; }
// Convert to CSV row
public string ToCsvRow()
{
return $"{Id},{Name},{Value},{Type}";
}
}
public class InventoryManager
{
private List<Item> items = new List<Item>();
public void AddItem(Item item)
{
items.Add(item);
}
public void SaveToCsv(string filePath)
{
try
{
using (StreamWriter writer = new StreamWriter(filePath))
{
// Write header
writer.WriteLine("Id,Name,Value,Type");
// Write each item
foreach (Item item in items)
{
writer.WriteLine(item.ToCsvRow());
}
}
Console.WriteLine($"Inventory saved to {filePath}");
}
catch (Exception ex)
{
Console.WriteLine($"Error saving inventory: {ex.Message}");
}
}
}
// Usage
public class Program
{
public static void Main()
{
InventoryManager inventory = new InventoryManager();
// Add some items
inventory.AddItem(new Item { Id = 1, Name = "Health Potion", Value = 25, Type = "Consumable" });
inventory.AddItem(new Item { Id = 2, Name = "Iron Sword", Value = 100, Type = "Weapon" });
inventory.AddItem(new Item { Id = 3, Name = "Leather Armor", Value = 75, Type = "Armor" });
// Save to CSV
inventory.SaveToCsv("inventory.csv");
}
}
Reading CSV Data
public class InventoryManager
{
private List<Item> items = new List<Item>();
// ... other methods ...
public void LoadFromCsv(string filePath)
{
try
{
// Clear existing items
items.Clear();
// Read all lines
string[] lines = File.ReadAllLines(filePath);
// Skip the header (first line)
for (int i = 1; i < lines.Length; i++)
{
string line = lines[i];
string[] values = line.Split(',');
if (values.Length >= 4)
{
Item item = new Item
{
Id = int.Parse(values[0]),
Name = values[1],
Value = int.Parse(values[2]),
Type = values[3]
};
items.Add(item);
}
}
Console.WriteLine($"Loaded {items.Count} items from {filePath}");
}
catch (Exception ex)
{
Console.WriteLine($"Error loading inventory: {ex.Message}");
}
}
public void DisplayInventory()
{
Console.WriteLine("Inventory Contents:");
Console.WriteLine("------------------");
foreach (Item item in items)
{
Console.WriteLine($"[{item.Id}] {item.Name} - {item.Value} gold ({item.Type})");
}
}
}
// Usage
public class Program
{
public static void Main()
{
InventoryManager inventory = new InventoryManager();
// Load from CSV
inventory.LoadFromCsv("inventory.csv");
// Display the loaded inventory
inventory.DisplayInventory();
}
}
This simple CSV parsing approach works for basic data, but it has limitations:
- It doesn't handle commas within field values
- It doesn't support quoted fields
- It doesn't handle escaped characters
For more robust CSV handling, consider using a dedicated CSV library or implementing a more sophisticated parser.
Reading and Writing Configuration Files
Configuration files are essential for storing game settings that need to persist between sessions. Let's create a simple key-value configuration system:
public class GameSettings
{
private Dictionary<string, string> settings = new Dictionary<string, string>();
private readonly string configFilePath;
public GameSettings(string filePath)
{
configFilePath = filePath;
LoadSettings();
}
// Get a setting with a default value if not found
public string GetSetting(string key, string defaultValue = "")
{
if (settings.TryGetValue(key, out string value))
{
return value;
}
return defaultValue;
}
// Get a setting as a specific type
public T GetSetting<T>(string key, T defaultValue)
{
if (settings.TryGetValue(key, out string stringValue))
{
try
{
// Convert the string value to the requested type
return (T)Convert.ChangeType(stringValue, typeof(T));
}
catch
{
return defaultValue;
}
}
return defaultValue;
}
// Set a setting
public void SetSetting(string key, object value)
{
settings[key] = value.ToString();
}
// Load settings from file
public void LoadSettings()
{
settings.Clear();
if (!File.Exists(configFilePath))
{
Console.WriteLine($"Config file not found: {configFilePath}");
return;
}
try
{
string[] lines = File.ReadAllLines(configFilePath);
foreach (string line in lines)
{
// Skip comments and empty lines
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#"))
{
continue;
}
// Split by the first equals sign
int equalsPos = line.IndexOf('=');
if (equalsPos > 0)
{
string key = line.Substring(0, equalsPos).Trim();
string value = line.Substring(equalsPos + 1).Trim();
settings[key] = value;
}
}
Console.WriteLine($"Loaded {settings.Count} settings from {configFilePath}");
}
catch (Exception ex)
{
Console.WriteLine($"Error loading settings: {ex.Message}");
}
}
// Save settings to file
public void SaveSettings()
{
try
{
// Create the directory if it doesn't exist
string directory = Path.GetDirectoryName(configFilePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
using (StreamWriter writer = new StreamWriter(configFilePath))
{
// Write a header comment
writer.WriteLine("# Game Configuration File");
writer.WriteLine("# Format: Key=Value");
writer.WriteLine();
// Write each setting
foreach (var kvp in settings)
{
writer.WriteLine($"{kvp.Key}={kvp.Value}");
}
}
Console.WriteLine($"Saved {settings.Count} settings to {configFilePath}");
}
catch (Exception ex)
{
Console.WriteLine($"Error saving settings: {ex.Message}");
}
}
}
// Usage example
public class Program
{
public static void Main()
{
GameSettings settings = new GameSettings("Config/gameSettings.cfg");
// Get settings with default values
bool fullscreen = settings.GetSetting("Fullscreen", false);
int musicVolume = settings.GetSetting("MusicVolume", 75);
float mouseSensitivity = settings.GetSetting("MouseSensitivity", 1.0f);
string playerName = settings.GetSetting("PlayerName", "Player");
Console.WriteLine($"Current Settings:");
Console.WriteLine($"- Fullscreen: {fullscreen}");
Console.WriteLine($"- Music Volume: {musicVolume}");
Console.WriteLine($"- Mouse Sensitivity: {mouseSensitivity}");
Console.WriteLine($"- Player Name: {playerName}");
// Change some settings
settings.SetSetting("Fullscreen", true);
settings.SetSetting("MusicVolume", 80);
settings.SetSetting("GraphicsQuality", "High");
// Save the changes
settings.SaveSettings();
}
}
This configuration system:
- Supports comments in the config file
- Handles different data types through generic methods
- Provides default values for missing settings
- Preserves settings between sessions
Text File I/O in Unity
In Unity, text file I/O is commonly used for:
-
Player Preferences: While Unity provides
PlayerPrefs
for simple settings, text files offer more flexibility for complex configurations. -
Level Editors: Saving and loading custom level designs.
-
Dialogue Systems: Storing conversation trees and localized text.
-
Debug Logs: Creating custom logging systems for debugging.
-
Data Tables: Storing game balance data like item stats, enemy properties, etc.
When working with text files in Unity, remember to use Application.persistentDataPath
for files that should persist between sessions:
// Example of saving a settings file in Unity
string settingsPath = Path.Combine(Application.persistentDataPath, "settings.txt");
File.WriteAllText(settingsPath, "resolution=1920x1080\nquality=high");
// Reading the settings
if (File.Exists(settingsPath))
{
string[] settings = File.ReadAllLines(settingsPath);
// Process settings...
}
For read-only data that ships with your game, you can use Application.streamingAssetsPath
:
string dialoguePath = Path.Combine(Application.streamingAssetsPath, "Dialogue", "intro.txt");
if (File.Exists(dialoguePath))
{
string dialogue = File.ReadAllText(dialoguePath);
// Process dialogue...
}
Best Practices for Text File I/O
-
Always use exception handling when reading or writing files
-
Close files promptly using the
using
statement -
Check if files exist before trying to read them
-
Create a consistent file format for your game data
-
Consider using established formats like JSON or XML for complex data (we'll cover these in section 9.4)
-
Implement validation for data loaded from files
-
Create backups of important files before overwriting them
-
Use relative paths when possible for better portability
-
Document your file formats for easier maintenance
-
Consider file size and loading time for larger text files
Conclusion
Text files provide a simple, flexible way to store and retrieve data in your games. Whether you're saving player preferences, logging game events, or storing game data, C#'s robust file I/O capabilities make working with text files straightforward.
In the next section, we'll explore working with file paths in more detail, focusing on cross-platform considerations and best practices for managing file locations in your games.