Skip to main content

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 GameConfiguration and GameState with System.Text.Json.
  • Configure JsonSerializerOptions (indentation, naming, enums as strings, ignore rules) and share one instance.
  • Handle records, init/required members, constructors, nullable results and the multidimensional-array limitation.
  • Recognise XML serialisation and explain why Newtonsoft.Json is off-limits in this course.
Demo code

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:

JSONC#
string "X"string, char, Guid, DateTime (ISO 8601), enums with a converter
number 7, 1.5int, 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, falsebool
nullnull — 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"
}
Multidimensional arrays are not supported

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.
  • init properties are set during deserialisation — the serializer is allowed to, your code is not.
  • required members must be present in the JSON, otherwise JsonException. Exactly what GameState.Config needs.
  • 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 null in the JSON is assigned even to a non-nullable reference property — nullable annotations are compile-time only. A hand-edited file can hand you a null board. Turn on RespectNullableAnnotations = true in the options (.NET 9+) so the serializer throws instead, and keep required on anything the engine cannot live without.
  • int?, DateTime? and friends map to a number or null.
  • 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

warning

(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 = true in 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 is not allowed

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 allowedSystem.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

  1. Which JSON value types exist, and how do they map to C#? — String, number, object, array, true/false, null; they map to string/Guid/DateTime, numeric types, classes and records, arrays and lists (one-dimensional, nestable), bool and nullable types.
  2. Why can a GameState with an EGamePiece[,] board not be saved with System.Text.Json? — Multidimensional arrays are unsupported and throw NotSupportedException; use a jagged array or a flat array with a width.
  3. What does JsonStringEnumConverter change, 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.
  4. Why share one static JsonSerializerOptions instance? — The options cache type metadata; a fresh instance per call rebuilds it every time, and a shared instance guarantees writer and reader agree.
  5. How does the serializer create a positional record on deserialisation? — Through its constructor, matching JSON property names to parameter names case-insensitively; extra init properties are set afterwards.
  6. What happens when a required property is missing from the JSON?Deserialize throws JsonException; a missing non-required property silently keeps its C# default.
  7. Why does a List<(int Row, int Col)> serialise as a list of empty objects? — Tuple members are fields, which are ignored by default; enable IncludeFields, use a small record, or map to a DTO.
  8. 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.