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 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​

  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.