04.1 - JSON Serialization
Recap
In 03.2 - Game Engine Design GameState became a plain class with public properties and a jagged board — on purpose. This lecture turns that object into text and back with System.Text.Json, which is what A3 needs to save and load games. Records, init and required from 02.2 - OOP 2 come back.
By the end of this lecture you should be able to:
- Explain what JSON is and which C# types map to which JSON values.
- Serialise and deserialise
GameConfigurationandGameStatewithSystem.Text.Json. - Configure
JsonSerializerOptions(indentation, naming, enums as strings, ignore rules) and share one instance. - Handle records,
init/requiredmembers, constructors, nullable results and the multidimensional-array limitation. - Recognise XML serialisation and explain why Newtonsoft.Json is off-limits in this course.
Lecture demos: csharp-2026-fall
JSON in two minutes
JSON — JavaScript Object Notation, json.org. Plain text, readable by humans, parsed by every language. Six kinds of values:
| JSON | C# |
|---|---|
string "X" | string, char, Guid, DateTime (ISO 8601), enums with a converter |
number 7, 1.5 | int, long, double, decimal |
object { ... } | class, record, struct, Dictionary<string, T> |
array [ ... ] | arrays, List<T>, any IEnumerable<T> — one-dimensional, but nestable, so jagged arrays are fine |
true, false | bool |
null | null — nullable types |
Serialization turns an object graph into text; deserialization turns text back into objects. A round trip does both, and the result must equal the original — that is the first test you write in A3.
This is a GameState from lecture 03.2 after three moves, serialised with indentation and enums as strings:
{
"Id": "8f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f",
"Config": {
"Name": "Connect3",
"BoardWidth": 5,
"BoardHeight": 4,
"WinLength": 3,
"IsCylinder": false
},
"Board": [
["Empty", "Empty", "Empty", "Empty", "Empty"],
["Empty", "Empty", "Empty", "Empty", "Empty"],
["Empty", "Empty", "O", "Empty", "Empty"],
["Empty", "X", "X", "Empty", "Empty"]
],
"NextMoveBy": "O",
"Moves": [
{ "Row": 3, "Col": 1 },
{ "Row": 2, "Col": 2 },
{ "Row": 3, "Col": 2 }
],
"CreatedAtUtc": "2026-09-25T10:15:30.123Z"
}
System.Text.Json throws NotSupportedException for int[,] and EGamePiece[,]. Use a jagged EGamePiece[][] (what the course does) or a flat EGamePiece[] plus a Width — both shown below.
System.Text.Json
Part of the base class library since .NET Core 3 — no NuGet package, two namespaces:
using System.Text.Json;
using System.Text.Json.Serialization;
var config = new GameConfiguration("Connect3", 5, 4, 3);
string json = JsonSerializer.Serialize(config);
// {"Name":"Connect3","BoardWidth":5,"BoardHeight":4,"WinLength":3,"IsCylinder":false}
GameConfiguration? back = JsonSerializer.Deserialize<GameConfiguration>(json);
The non-generic overloads take a Type for the cases where the type is only known at runtime:
object? obj = JsonSerializer.Deserialize(json, typeof(GameConfiguration));
string json2 = JsonSerializer.Serialize(config, typeof(GameConfiguration));
Deserialize returns T? because the text null is valid JSON. With nullable warnings as errors you have to deal with it, and the honest way is to fail loudly:
var config = JsonSerializer.Deserialize<GameConfiguration>(json)
?? throw new JsonException("Configuration JSON was null.");
What gets serialised: public properties with a public getter. Reading back needs a public setter or init, or a matching constructor parameter. Fields, private members and static members are skipped unless you opt in.
There are also stream overloads — Serialize(stream, value), Deserialize<T>(stream) — and async ones, SerializeAsync / DeserializeAsync. 04.2 - Files and Persistence uses them.
JsonSerializerOptions
var options = new JsonSerializerOptions
{
WriteIndented = true, // pretty print files a human will open
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, // "boardWidth" instead of "BoardWidth"
PropertyNameCaseInsensitive = true, // accept either when reading
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, // skip properties that are null
AllowTrailingCommas = true, // tolerate hand-edited files
Converters = { new JsonStringEnumConverter() }, // enums as "X", not 1
};
var json = JsonSerializer.Serialize(state, options);
var back = JsonSerializer.Deserialize<GameState>(json, options);
Naming policy: for files only your own program reads, the default (property names as written in C#) is simplest. Web APIs conventionally use camelCase — JsonSerializerOptions.Web is a ready-made preset for that. Whatever you choose, use the same options for writing and reading.
Enums as strings
Without a converter NextMoveBy is written as 2. That breaks the moment somebody reorders the enum, and it is unreadable in a file. Two ways to fix it: the converter in the options above, or — better, because it cannot be forgotten — an attribute on the enum itself:
[JsonConverter(typeof(JsonStringEnumConverter<EGamePiece>))]
public enum EGamePiece { Empty, X, O }
The generic JsonStringEnumConverter<TEnum> is the modern form; the non-generic one still works.
One shared instance
JsonSerializerOptions builds and caches metadata for every type it meets. Creating a new instance per call throws that cache away. Make one, share it, never modify it after first use (it becomes read-only and throws if you try):
namespace DAL.Json;
public static class JsonDefaults
{
public static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() },
};
}
Attributes
public class GameState
{
public Guid Id { get; set; } = Guid.NewGuid();
[JsonPropertyName("board")] // name used in the file, independent of the naming policy
public EGamePiece[][] Board { get; set; } = [];
[JsonIgnore] // never written, never read
public int MoveCount => Moves.Count; // derived - compute it, do not store it
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public string? Comment { get; set; } // written only when set
[JsonInclude] // opt in a non-public setter (or a public field)
public DateTime CreatedAtUtc { get; private set; } = DateTime.UtcNow;
public required GameConfiguration Config { get; init; }
public EGamePiece NextMoveBy { get; set; } = EGamePiece.X;
public List<(int Row, int Col)> Moves { get; set; } = [];
}
A get-only computed property like MoveCount is written by default and silently ignored on reading. [JsonIgnore] keeps the file honest: nothing derived gets stored.
Records, init, required and constructors
- A positional record deserialises through its constructor. Parameter names must match the JSON property names (case-insensitive). No attributes needed.
initproperties are set during deserialisation — the serializer is allowed to, your code is not.requiredmembers must be present in the JSON, otherwiseJsonException. Exactly whatGameState.Configneeds.- A class with several constructors needs
[JsonConstructor]on the one to use, or a public parameterless one.
public record GameConfiguration(string Name, int BoardWidth, int BoardHeight, int WinLength)
{
public bool IsCylinder { get; init; } = false; // not a ctor parameter - set through init afterwards
}
var text = """{ "Name": "Mini", "BoardWidth": 4, "BoardHeight": 4, "WinLength": 3 }""";
var mini = JsonSerializer.Deserialize<GameConfiguration>(text)
?? throw new JsonException("Empty configuration.");
// mini.IsCylinder == false - missing property keeps the C# default
public class Player
{
public string Name { get; }
public int Wins { get; }
[JsonConstructor]
public Player(string name, int wins) => (Name, Wins) = (name, wins);
public Player(string name) : this(name, 0) { }
}
Nullable handling
- A missing property in the JSON leaves the C# property at its initialiser value. No error, unless the member is
required. - An explicit
nullin the JSON is assigned even to a non-nullable reference property — nullable annotations are compile-time only. A hand-edited file can hand you anullboard. Turn onRespectNullableAnnotations = truein the options (.NET 9+) so the serializer throws instead, and keeprequiredon anything the engine cannot live without. int?,DateTime?and friends map to a number ornull.- Malformed text →
JsonException. Catch it where you can tell the user something useful (the repository or the UI), never swallow it.
var options = new JsonSerializerOptions(JsonDefaults.Options) { RespectNullableAnnotations = true };
The board problem
// jagged - what GameState uses; serialises as an array of arrays
public EGamePiece[][] Board { get; set; } = [];
// flat array + width - one array, index arithmetic in a helper
public EGamePiece[] Cells { get; set; } = [];
public int Width { get; set; }
public EGamePiece Get(int row, int col) => Cells[row * Width + col];
If you insist on EGamePiece[,] inside the engine, convert at the boundary: a DTO with a jagged array, mapped both ways in the repository (04.2 - Files and Persistence), or a custom JsonConverter<EGamePiece[,]>. The DTO is less code and easier to explain at defense.
Tuples in Moves
(int Row, int Col) is a ValueTuple — its members Item1/Item2 are fields, and the names Row/Col exist only at compile time. Default serialisation writes each move as {} — an empty object — and your move history is gone after the first save. Three fixes:
IncludeFields = truein the options →{"Item1":3,"Item2":1}. Works, ugly, and it turns on fields for every type.- A tiny record
public record Move(int Row, int Col);→{"Row":3,"Col":1}. Readable. - Keep the tuple in the engine and map it to a DTO in the data layer — the example in lecture 21.
Whichever you pick, the round-trip test catches it if you forget.
Polymorphism, briefly
Games with several move kinds (Tic-Tac-Two, Nine Men's Morris) store a list of a base type. The serializer writes only the base type's properties unless you declare the derived types; then it adds a $type discriminator and reads them back correctly:
[JsonDerivedType(typeof(PlacePiece), "place")]
[JsonDerivedType(typeof(MovePiece), "move")]
[JsonDerivedType(typeof(MoveGrid), "grid")]
public abstract record GameMove;
public record PlacePiece(int Row, int Col) : GameMove;
public record MovePiece(int FromRow, int FromCol, int ToRow, int ToCol) : GameMove;
public record MoveGrid(int DRow, int DCol) : GameMove;
{ "$type": "place", "Row": 2, "Col": 2 }
Source generation, a mention
By default the serializer inspects your types with reflection at runtime. A JsonSerializerContext generates that metadata at compile time instead — faster startup, required for trimmed / AOT builds. Not needed in this course; recognise it when you see it:
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(GameState))]
[JsonSerializable(typeof(GameConfiguration))]
internal partial class GameJsonContext : JsonSerializerContext;
var json = JsonSerializer.Serialize(state, GameJsonContext.Default.GameState);
XML in ten lines
Learning outcome L03 mentions XML as a data source. The .NET tool is XmlSerializer in System.Xml.Serialization: it needs a public class with a public parameterless constructor and public read/write properties — so a positional record will not do, use a DTO.
using System.Xml.Serialization;
var serializer = new XmlSerializer(typeof(GameConfigurationDto));
using (var writer = new StreamWriter("classic.xml"))
{
serializer.Serialize(writer, dto);
}
using (var reader = new StreamReader("classic.xml"))
{
var back = (GameConfigurationDto?)serializer.Deserialize(reader);
}
<?xml version="1.0" encoding="utf-8"?>
<GameConfigurationDto>
<Name>Classic</Name>
<BoardWidth>7</BoardWidth>
<BoardHeight>6</BoardHeight>
<WinLength>4</WinLength>
</GameConfigurationDto>
Like System.Text.Json, XmlSerializer handles jagged arrays and not [,]. For querying XML there is LINQ to XML: XDocument.Load(path).Descendants("Game") and then the LINQ you already know. An XML export/import of configurations or games is on the bonus list.
Newtonsoft.Json (Json.NET) is a mature third-party library; it does support [,] arrays and it is what a lot of older tutorials and AI-generated code reach for. In this course, and in the spring web course, it is not allowed — System.Text.Json is the standard, ships with .NET, and everything ASP.NET Core does is built on it. Circular references (Player.Game.Player...) are forbidden too: the serializer throws JsonException, and the fix is a better model, not a ReferenceHandler setting.
Self preparation QA
- Which JSON value types exist, and how do they map to C#? — String, number, object, array,
true/false,null; they map tostring/Guid/DateTime, numeric types, classes and records, arrays and lists (one-dimensional, nestable),booland nullable types. - Why can a
GameStatewith anEGamePiece[,]board not be saved withSystem.Text.Json? — Multidimensional arrays are unsupported and throwNotSupportedException; use a jagged array or a flat array with a width. - What does
JsonStringEnumConverterchange, and why does the course want it? — Enums are written as their names ("X") instead of their integer values, so files stay readable and survive a reordered enum. - Why share one static
JsonSerializerOptionsinstance? — The options cache type metadata; a fresh instance per call rebuilds it every time, and a shared instance guarantees writer and reader agree. - How does the serializer create a positional record on deserialisation? — Through its constructor, matching JSON property names to parameter names case-insensitively; extra
initproperties are set afterwards. - What happens when a
requiredproperty is missing from the JSON? —DeserializethrowsJsonException; a missing non-required property silently keeps its C# default. - Why does a
List<(int Row, int Col)>serialise as a list of empty objects? — Tuple members are fields, which are ignored by default; enableIncludeFields, use a small record, or map to a DTO. - Why is Newtonsoft.Json banned here even though it supports
[,]? — The course teaches the built-in standard that ASP.NET Core uses; the array limitation is a modelling problem to solve with a jagged array, not a reason to add a third-party dependency.