04.2 - Files, Directories and Persistence Design
Recap
04.1 - JSON turned GameState and GameConfiguration into text. This lecture puts that text on disk — in the right place, safely — and then designs the repository contracts that A3 (DAL.Json) and A4 (DAL.EF) both implement, while the engine from 03.2 - Game Engine Design stays untouched.
By the end of this lecture you should be able to:
- Read, write, list and delete files with
File,DirectoryandPathwithout a single hardcoded path. - Put saves in the user's home directory in a cross-platform way and write them atomically.
- Decide what a saved game must contain and when a DTO is worth it.
- Define
IConfigRepositoryandIGameRepositoryinGameEngineand implement them inDAL.Json. - Version the save format and seed preset configurations on first run.
Lecture demos: csharp-2026-fall
System.IO — three static classes
File
var path = "classic.config.json";
File.WriteAllText(path, json); // create or overwrite, UTF-8
var text = File.ReadAllText(path); // the whole file as one string
var exists = File.Exists(path); // never throws
File.Delete(path); // no error when the file does not exist
File.Copy(path, "backup.json", overwrite: true);
File.Move(path, "renamed.json", overwrite: true);
var lines = File.ReadAllLines(path); // string[]
File.AppendAllText("log.txt", $"{DateTime.UtcNow:O} saved {path}\n");
Every read/write has an async twin with the same name: await File.ReadAllTextAsync(path), await File.WriteAllTextAsync(path, json). A console app can stay synchronous; the web app in Week 13 uses the async versions.
Directory
Directory.CreateDirectory(folder); // creates missing parents too; no error when it exists
var exists = Directory.Exists(folder);
string[] files = Directory.GetFiles(folder, "*.game.json"); // full paths, this folder only
foreach (var file in Directory.EnumerateFiles(folder, "*.config.json", SearchOption.TopDirectoryOnly))
{
Console.WriteLine(Path.GetFileName(file));
}
Directory.Delete(folder, recursive: true);
GetFiles returns everything at once as an array; EnumerateFiles is lazy and LINQ-friendly. The pattern supports * and ? only — no regex.
Path
Never glue paths together with + "/" +.
var folder = Path.Combine(home, ".icd0008", "connect-four"); // the right separator for the OS
var file = Path.Combine(folder, $"{state.Id}.game.json");
Path.GetFileName(file); // "8f1c....game.json"
Path.GetFileNameWithoutExtension(file); // "8f1c....game" - removes only the LAST extension
Path.GetExtension(file); // ".json"
Path.GetDirectoryName(file); // the folder
Path.ChangeExtension(file, ".bak");
Path.GetTempPath(); // a writable scratch folder - good for tests
Path.DirectorySeparatorChar; // '/' on macOS and Linux, '\' on Windows
Because GetFileNameWithoutExtension("Classic.config.json") returns Classic.config, strip a two-part suffix yourself:
const string ConfigSuffix = ".config.json";
var name = Path.GetFileName(file)[..^ConfigSuffix.Length]; // "Classic"
Where do the files go?
Not next to the executable (bin/Debug/... is wiped by dotnet clean) and not in the repository. The user's home directory plus a folder for your app:
namespace DAL.Json;
public static class AppPaths
{
public static string Root { get; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".icd0008", "connect-four");
public static string Configs => Path.Combine(Root, "configs");
public static string Games => Path.Combine(Root, "games");
}
That resolves to C:\Users\andres\.icd0008\connect-four on Windows, /Users/andres/.icd0008/connect-four on macOS and /home/andres/.icd0008/connect-four on Linux — from the same line of code. SpecialFolder.ApplicationData (AppData\Roaming, ~/.config) is the more "proper" location; either is fine for the course.
A literal C:\Users\andres\... or /Users/andres/... anywhere in your code breaks the fresh-clone requirement — the TA runs your solution on a different laptop. Same rule for the SQLite file in A4.
Streams, briefly
File.ReadAllText is a convenience wrapper around a FileStream. You meet the stream API when a file is big, when data comes from the network, or when you want the serializer to write straight to the file:
await using (var stream = File.Create(path)) // FileStream
{
await JsonSerializer.SerializeAsync(stream, state, JsonDefaults.Options);
}
using (var reader = new StreamReader(path)) // text over a stream
{
while (reader.ReadLine() is { } line)
{
Console.WriteLine(line);
}
}
using (var writer = new StreamWriter(path, append: true))
{
writer.WriteLine("appended");
}
using disposes the stream, which flushes buffers and releases the file handle. Forget it and you get empty files and "file is in use by another process" errors.
Atomic writes
Power off or a crash in the middle of WriteAllText leaves half a JSON file; the next start greets you with JsonException. Write to a temporary file first and rename — a rename on the same volume either happens completely or not at all:
namespace DAL.Json;
public static class FileHelpers
{
public static void WriteAtomic(string path, string content)
{
var temp = path + ".tmp";
File.WriteAllText(temp, content);
File.Move(temp, path, overwrite: true);
}
public static string SafeName(string name)
{
var invalid = Path.GetInvalidFileNameChars();
return new string(name.Select(c => invalid.Contains(c) ? '_' : c).ToArray());
}
}
IO exceptions
FileNotFoundException and DirectoryNotFoundException (both derive from IOException), UnauthorizedAccessException (permissions), plain IOException (disk full, file locked) — plus JsonException when the content is not what you expect. Check File.Exists before reading to turn the common case into a clear message, and catch the rest where you can tell the user something useful:
try
{
var state = gameRepository.Get(id);
GameRunner.Run(state, human, human, gameRepository);
}
catch (KeyNotFoundException)
{
Console.WriteLine("That saved game no longer exists.");
}
catch (JsonException e)
{
Console.WriteLine($"The save file is corrupt: {e.Message}");
}
catch (IOException e)
{
Console.WriteLine($"Could not read the save: {e.Message}");
}
Persistence design (A3)
What to store
| Store | Why |
|---|---|
| Configuration (inside the game) | a saved game must be playable even if the preset changes |
| State: board, next player, phase, pending capture | to continue exactly where the game stopped |
| Move history | replay, undo, "last move" highlight, debugging a rules bug from a real save |
| Players: names and, from A5, human/AI + difficulty | the web app (A6) shows who plays, the AI needs to know its own turn |
Timestamps: CreatedAtUtc, SavedAtUtc | sorting the game list, "continue last game" |
Ids: Guid per game, the name per configuration | stable keys in files, database rows and URLs |
| Format version | so that next month's program can still read this month's files |
Do not store what you can recompute: status, legal moves, piece counts. Stored derived data eventually disagrees with the data it was derived from.
DTO vs domain object
The domain object is GameState — what the engine works with. A DTO (data transfer object) is a plain class shaped for the file. Use the domain object directly when it is already file-friendly: public properties, no tuples, no [,]. Introduce a DTO when the domain type has something the serializer cannot handle, or when you want to change the engine without breaking old files. The tuple move history from lecture 04.1 is exactly such a case:
using GameEngine;
namespace DAL.Json;
public class GameStateDto
{
public int Version { get; set; } = SaveFormat.Current;
public Guid Id { get; set; }
public required GameConfiguration Config { get; set; }
public EGamePiece[][] Board { get; set; } = [];
public EGamePiece NextMoveBy { get; set; }
public List<MoveDto> Moves { get; set; } = [];
public DateTime CreatedAtUtc { get; set; }
public DateTime SavedAtUtc { get; set; }
}
public class MoveDto
{
public int Row { get; set; }
public int Col { get; set; }
}
public static class GameStateMapper
{
public static GameStateDto ToDto(GameState state) => new()
{
Id = state.Id,
Config = state.Config,
Board = state.Board,
NextMoveBy = state.NextMoveBy,
Moves = state.Moves.Select(m => new MoveDto { Row = m.Row, Col = m.Col }).ToList(),
CreatedAtUtc = state.CreatedAtUtc,
SavedAtUtc = DateTime.UtcNow,
};
public static GameState ToDomain(GameStateDto dto) => new()
{
Id = dto.Id,
Config = dto.Config,
Board = dto.Board,
NextMoveBy = dto.NextMoveBy,
Moves = dto.Moves.Select(m => (m.Row, m.Col)).ToList(),
CreatedAtUtc = dto.CreatedAtUtc,
};
}
The DTO lives in DAL.Json, next to the code that reads and writes it. GameEngine never sees it. In A4 the EF entity classes play the same role for the database.
File naming and ids
~/.icd0008/connect-four/
├── configs/
│ ├── Classic.config.json {name}.config.json - the name is the key
│ ├── Cylinder.config.json
│ └── My-weird-board.config.json
└── games/
├── 8f1c2d3e-....game.json {guid}.game.json - the id is the key
└── b2a7e0c1-....game.json
- A configuration's key is its name. Sanitise it with
FileHelpers.SafeNameand remember that Windows and macOS file systems are case-insensitive —classicandClassicare the same file. - A game's key is its
Guid: unique without a database, generated in the engine, identical in JSON (A3), SQLite (A4) and the URL (A6). That is what makes a console-started game continuable in the web app.
CRUD semantics
| Operation | Contract |
|---|---|
List() | lightweight: names, or (Id, Name, SavedAtUtc); never full states |
Get(key) | the object, or KeyNotFoundException when missing — not null, not a default object |
Save(object) | upsert: create or overwrite by key; saving the same id twice leaves one file |
Delete(key) | idempotent: no error when nothing exists |
Decide these once, write them into the interface's XML doc comments, and make every implementation honour them. In A4 the contract tests run against both DAL.Json and DAL.EF — they can only pass if the semantics are identical. With JSON files, List() has to open every game file to read the name and timestamp; with a database it is one query. The interface does not care.
The interfaces live in GameEngine
namespace GameEngine;
public interface IConfigRepository
{
List<string> List();
GameConfiguration Get(string name);
void Save(GameConfiguration config);
void Delete(string name);
}
public interface IGameRepository
{
List<(Guid Id, string Name, DateTime SavedAtUtc)> List();
GameState Get(Guid id);
void Save(GameState state);
void Delete(Guid id);
}
The interface sits in the project everybody references; the implementation sits in a project only the composition root (Program.cs) references. The game loop takes an IGameRepository and never learns whether it is talking to a folder or a database.
ConfigRepositoryJson
using System.Text.Json;
using GameEngine;
namespace DAL.Json;
public class ConfigRepositoryJson : IConfigRepository
{
private const string Suffix = ".config.json";
private readonly string _folder;
// the folder parameter exists for tests - point it at a temp directory
public ConfigRepositoryJson(string? folder = null)
{
_folder = folder ?? AppPaths.Configs;
Directory.CreateDirectory(_folder);
SeedPresets();
}
public List<string> List() =>
Directory.EnumerateFiles(_folder, "*" + Suffix)
.Select(file => Path.GetFileName(file)[..^Suffix.Length])
.OrderBy(name => name)
.ToList();
public GameConfiguration Get(string name)
{
var path = PathFor(name);
if (!File.Exists(path))
throw new KeyNotFoundException($"Configuration '{name}' not found.");
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<GameConfiguration>(json, JsonDefaults.Options)
?? throw new JsonException($"Configuration '{name}' is empty.");
}
public void Save(GameConfiguration config)
{
var json = JsonSerializer.Serialize(config, JsonDefaults.Options);
FileHelpers.WriteAtomic(PathFor(config.Name), json);
}
public void Delete(string name) => File.Delete(PathFor(name));
private string PathFor(string name) => Path.Combine(_folder, FileHelpers.SafeName(name) + Suffix);
private void SeedPresets()
{
if (List().Count > 0) return; // only on first run - never overwrite the user's edits
foreach (var preset in Presets.All)
{
Save(preset);
}
}
}
GameRepositoryJson
Same structure, written with a primary constructor this time:
using System.Text.Json;
using GameEngine;
namespace DAL.Json;
public class GameRepositoryJson(string? folder = null) : IGameRepository
{
private const string Suffix = ".game.json";
private readonly string _folder = EnsureFolder(folder ?? AppPaths.Games);
private static string EnsureFolder(string folder)
{
Directory.CreateDirectory(folder);
return folder;
}
public List<(Guid Id, string Name, DateTime SavedAtUtc)> List() =>
Directory.EnumerateFiles(_folder, "*" + Suffix)
.Select(ReadDto)
.OrderByDescending(dto => dto.SavedAtUtc)
.Select(dto => (dto.Id, dto.Config.Name, dto.SavedAtUtc))
.ToList();
public GameState Get(Guid id)
{
var path = PathFor(id);
if (!File.Exists(path))
throw new KeyNotFoundException($"Game {id} not found.");
return GameStateMapper.ToDomain(ReadDto(path));
}
public void Save(GameState state)
{
var dto = GameStateMapper.ToDto(state);
var json = JsonSerializer.Serialize(dto, JsonDefaults.Options);
FileHelpers.WriteAtomic(PathFor(state.Id), json);
}
public void Delete(Guid id) => File.Delete(PathFor(id));
private string PathFor(Guid id) => Path.Combine(_folder, id + Suffix);
private static GameStateDto ReadDto(string path)
{
var json = File.ReadAllText(path);
var dto = JsonSerializer.Deserialize<GameStateDto>(json, JsonDefaults.Options)
?? throw new JsonException($"{path} is empty.");
return SaveFormat.Upgrade(dto);
}
}
Versioning the save format
You will change GameState after D1 — players arrive in A5, and your own bug fixes will move things around. A version number in every file lets the reader know what it is looking at:
namespace DAL.Json;
public static class SaveFormat
{
public const int Current = 2;
public static GameStateDto Upgrade(GameStateDto dto)
{
if (dto.Version > Current)
throw new NotSupportedException($"Save version {dto.Version} is newer than this program ({Current}).");
if (dto.Version < 2)
{
dto.SavedAtUtc = dto.CreatedAtUtc; // v1 had no SavedAtUtc
dto.Version = 2;
}
return dto;
}
}
Upgrading on read is the friendly option. Refusing old files with a clear message is acceptable in this course; silently loading garbage is not.
Seeding presets on first run
ConfigRepositoryJson above seeds the presets from Presets.All when the folder is empty. Rules: seed only when nothing is there, never overwrite a user's edited preset, and keep the preset list in GameEngine so DAL.EF seeds the same four in A4. If you want presets to be undeletable, add an IsPreset flag to the configuration and let the UI hide the delete option.
Using it from ConsoleUI
using DAL.Json;
using GameEngine;
IConfigRepository configRepository = new ConfigRepositoryJson();
IGameRepository gameRepository = new GameRepositoryJson();
var human = new ConsoleMoveProvider();
// new game from a chosen configuration
var config = configRepository.Get(chosenName);
GameRunner.Run(GameState.New(config), human, human, gameRepository);
// continue a saved game
foreach (var (id, name, savedAt) in gameRepository.List())
{
Console.WriteLine($"{id} {name} {savedAt:g}");
}
GameRunner.Run(gameRepository.Get(selectedId), human, human, gameRepository);
GameRunner.Run from lecture 03.2 gets one extra parameter and one extra line — gameRepository.Save(state) after every successful move. The variables are declared as the interfaces: in A4 the two new lines change to new ConfigRepositoryEf(db) / new GameRepositoryEf(db) and nothing else moves. In Week 8 dependency injection does even the new for you — 08.1 - Repository & DI.
A round-trip test belongs to A3 and runs against a temporary folder:
[Fact]
public void SaveThenGet_ReturnsEqualState()
{
var folder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
IGameRepository repo = new GameRepositoryJson(folder);
var state = GameState.New(Presets.Classic);
new GameBrain(state).MakeMove(5, 3);
repo.Save(state);
var loaded = repo.Get(state.Id);
Assert.Equal(state.Id, loaded.Id);
Assert.Equal(state.Config, loaded.Config); // record equality
Assert.Equal(state.Moves, loaded.Moves);
Assert.Equal(state.Board, loaded.Board); // xUnit compares nested collections element by element
Directory.Delete(folder, recursive: true);
}
Self preparation QA
- Why
Path.Combineinstead of string concatenation? — It inserts the separator that is right for the running OS and handles trailing separators, so the same code works on Windows, macOS and Linux. - Where should the JSON saves live, and how do you find that folder in code? — In the user's home (or application data) directory under an app-specific folder, obtained with
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)— never an absolute path typed into the source. - What does an atomic write protect against? — A crash mid-write leaving a half-written file: writing to a temp file and renaming means the real file is either the old complete version or the new complete version.
- What belongs in a saved game and what does not? — Configuration, board, next player, move history, players, timestamps, id and a format version; nothing derived such as status or legal moves.
- When is a DTO worth the extra class? — When the domain type contains something the serializer cannot handle (tuples,
[,], private state) or when you want the file format to stay stable while the engine changes. - Why are
IConfigRepositoryandIGameRepositorydefined inGameEngineand not inDAL.Json? — So the UI and engine depend only on the contract, andDAL.JsonandDAL.EFcan both implement it without the engine referencing either. - What are the agreed semantics of
GetandDelete? —GetthrowsKeyNotFoundExceptionfor a missing key,Deleteis idempotent; both implementations must behave identically, which the A4 contract tests verify. - How does the JSON to EF switch in A4 stay a two-line change? — Consumers hold the interface types; only the two
newexpressions (later, the DI registrations) name a concrete implementation.