# Saving the Game: System.Text.Json and Union State

> .NET 11 System.Text.Json understands C# 15 unions. We save and load game state to see what works natively, what needs an attribute, and what needs a workaround.

- URL: https://blog.nimblepros.com/blogs/saving-the-game-system-text-json-and-union-state/
- Author: [Sarah Dutkiewicz](https://blog.nimblepros.com/authors/sadukie/)
- Published: 2026-10-22
- Updated: 2026-11-06
- Series: [Tiles & Tokens](https://blog.nimblepros.com/series/tiles-and-tokens/)
- Categories: [Software Development](https://blog.nimblepros.com/categories/software-development/), [Domain-Driven Design](https://blog.nimblepros.com/categories/domain-driven-design/)
- Publisher: [NimblePros](https://blog.nimblepros.com/)

---

*This post is part 4 of a 6-part series on domain modeling with C# 15 and .NET 11, told through a small board game.*

Pause the game, close the app, and pick it back up tomorrow. Where does the state go?

So far in this series, Tiles & Tokens has lived entirely in memory. In [part 2](../discriminated-unions-in-csharp-15-a-turn-is-exactly-one-thing/), we modeled actions, outcomes, and cards as unions. In [part 3](../closed-hierarchies-in-csharp-15-every-victory-condition-accounted-for/), we modeled victory conditions and placement rules as closed hierarchies. Now we need to write a game to disk and read it back - board, players, hands, deck, the victory condition, and the full turn log.

That's a good stress test, because a save file is where type safety usually leaks out. A `GameAction` is "exactly one of three things" in memory. In JSON, it's just an object, and something has to remember which of the three it was.

System.Text.Json in .NET 11 knows about unions. The question for this post is: **how much does that buy us?**

The code for this post is at the [`post-4` tag](https://github.com/NimblePros/tiles-and-tokens-net-11/tree/post-4) of the companion repo, in `src/TilesAndTokens.Serialization`. Everything here was verified on the .NET 11 RC1 SDK (`11.0.100-rc.1.26425.128`), with System.Text.Json `11.0.0-rc.1.26425.128`.

## The Old Way

Before C# 15, you had two common options for saving "one of several types." Both are in the repo's `TilesAndTokens.Legacy` project, so we can see where they hurt.

### Option 1: Polymorphism attributes

For a class hierarchy, System.Text.Json has supported polymorphism since .NET 7. You list every subtype and give each one a discriminator:

```csharp
[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
[JsonDerivedType(typeof(RollDiceAction), "rollDice")]
[JsonDerivedType(typeof(PlayCardAction), "playCard")]
[JsonDerivedType(typeof(PassAction), "pass")]
public abstract class LegacyAction(string playerId)
{
    public string PlayerId { get; } = playerId;
}
```

That works well - until someone adds a subtype. Remember `TradeAction` from [part 1](../rolling-into-dotnet-11-modeling-a-board-game-with-csharp-15/)? It isn't in the list. Nothing tells you so, until you try to save a game that contains one:

```csharp
[Fact]
public void DerivedTypeAttributes_UnlistedSubclass_CompilesButThrowsAtRuntime()
{
    LegacyAction trade = new TradeAction("alice", "bob", "forest-1", "water-1");

    var ex = Should.Throw<NotSupportedException>(() => JsonSerializer.Serialize(trade, Options));

    ex.Message.ShouldContain(nameof(TradeAction));
}
```

The attribute list is one more place that has to be kept in sync with an open hierarchy, by hand.

### Option 2: A hand-written converter

For the null-heavy `TurnAction` wrapper, the usual answer is a custom `JsonConverter`. The repo's version is 63 non-blank lines. Here's the reading side:

```csharp
switch (root.GetProperty("kind").GetString())
{
    case "pass":
        action.IsPass = true;
        break;

    case "rollDice":
        action.Roll = new DiceRoll(root.GetProperty("first").GetInt32(), root.GetProperty("second").GetInt32());
        break;

    case "playCard":
        action.Card = root.GetProperty("card").Deserialize<Card>(options);
        // ... target ...
        break;

    default:
        // Same trap as StringTurnProcessor: a typo in a save file just yields an empty action.
        break;
}
```

Every property is read and written by hand, and the `"kind"` values are just strings again. The tests in `LegacyJsonTests` pin down two problems: an invalid turn with a roll *and* a card silently loses the card when saved, and a typo like `"rolldice"` in the file loads as an empty action - no error.

## .NET 11 Out of the Box

Let's start with no attributes and no converters at all - just `JsonSerializer.Serialize(value)` and `JsonSerializer.Deserialize<T>(json)` on the C# 15 types.

| Value | JSON written | Reads back? |
|---|---|---|
| `GameAction` holding `RollDice` | `{"Player":{"Value":"alice"}}` - the bare case value | ❌ |
| `List<GameAction>` | An array of bare case values | ❌ |
| `Card` holding `TileCard` | `{"Id":{"Value":"forest-1"},"Terrain":1}` | ❌ |
| `VictoryCondition` holding `MostPoints(10)` | `{"Name":"Most points after 10 rounds"}` - **`Rounds` is silently lost** | ❌ |

The union errors all look like this:

```text
JsonException: JSON value type 'Object' is ambiguous for union type 'GameAction' because multiple case types can use this value type. Specify a custom type classifier to support deserialization.
```

And the closed hierarchy:

```text
NotSupportedException: Deserialization of interface or abstract types is not supported. Type 'VictoryCondition'.
```

So here's the headline: **System.Text.Json 11 knows about unions, but it can't read them back by default, and it doesn't know about closed hierarchies at all.**

"Knows about unions" is still meaningful. It writes the *case value*, not the wrapper struct with its `Value` property. And that error message is a hint: it asks for a **type classifier**, which is new in .NET 11. Let's take the three types one at a time.

### `Card`: one attribute, fully native

A tile card has a `terrain`. A bonus card has `points`. Since the two cases have different properties, System.Text.Json can tell them apart by looking at the JSON's shape - and it ships a classifier that does exactly that:

```csharp
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union Card(TileCard, BonusPointsCard)
{
    // ...
}
```

That's it. Cards now round-trip with no discriminator in the JSON at all:

```json
[
  { "id": { "value": "water-2" }, "terrain": "Water" },
  { "id": { "value": "bonus-1" }, "points": 3 }
]
```

This is the best case for unions in System.Text.Json 11: **when the cases have different shapes, one attribute is all you need.**

### `VictoryCondition`: the same attributes as before

Closed hierarchies don't get new serialization support. System.Text.Json doesn't discover a closed type's subtypes on its own. `[JsonPolymorphic]` by itself fails with *"Polymorphic configuration for type ... should specify at least one derived type"*, so we list them, exactly like an open hierarchy:

```csharp
[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
[JsonDerivedType(typeof(MostPoints), "mostPoints")]
[JsonDerivedType(typeof(FirstToEdge), "firstToEdge")]
[JsonDerivedType(typeof(LastPlayerStanding), "lastPlayerStanding")]
public closed record VictoryCondition
{
    public abstract string Name { get; }
}
```

```json
{ "kind": "mostPoints", "rounds": 10 }
```

There's no less code here than before C# 15. What's different is the guarantee around it: in part 3, we saw that every subtype of `VictoryCondition` has to live in the domain assembly. The list is short, it's in one place, and it can't grow from somewhere else in your solution without you noticing.

### `GameAction`: not possible natively on RC1

Here's where it gets interesting. Look at these two actions:

```csharp
public sealed record RollDice(PlayerId Player);
public sealed record Pass(PlayerId Player);
```

They have **exactly the same properties**. Both serialize to `{"player":{"value":"alice"}}`. Once that's written, nothing in the JSON says whether it was a roll or a pass, so no classifier can figure it out on the way back in. The information is gone on write.

The obvious fixes don't work on RC1 either:

- **The structural classifier** rejects the type: *"The JsonUnionTypeStructuralClassifier cannot classify union type 'GameAction' because the case type 'RollDice' can never be selected uniquely."*
- **`[JsonPolymorphic]` can't go on a union**, because unions are structs: *CS0592: Attribute 'JsonPolymorphic' is not valid on this declaration type. It is only valid on 'class, interface' declarations.*
- **Making each case polymorphic over itself** fails: *"Specified type 'RollDice' does not support polymorphism. Polymorphic types cannot be structs, sealed types or System.Object."*
- **There's no union discriminator setting.** The classifier's context has a `TypeDiscriminatorPropertyName`, but it's `null` for unions.

What we need is a `"kind"` property, like `VictoryCondition` gets. On RC1, we have to add it ourselves.

## The Workaround: Two Extension Points, No Converter

The repo's workaround lives in `src/TilesAndTokens.Serialization/GameActionKinds/`, and every file is labeled `WORKAROUND` so it's easy to delete when a built-in option shows up. It doesn't use a `JsonConverter`. Instead, it plugs into two System.Text.Json extension points, so the serializer still reads and writes every property itself.

**1. A list of names.** Each case type gets a discriminator:

```csharp
public static UnionCaseKinds Default { get; } = new(new()
{
    [typeof(RollDice)] = "rollDice",
    [typeof(PlayCard)] = "playCard",
    [typeof(Pass)] = "pass",
});
```

**2. Writing: a contract modifier** that adds a `"kind"` property to each case type's JSON contract:

```csharp
public static Action<JsonTypeInfo> Create(UnionCaseKinds kinds) => typeInfo =>
{
    if (typeInfo.Kind != JsonTypeInfoKind.Object || !kinds.TryGetKind(typeInfo.Type, out var kind))
    {
        return;
    }

    var property = JsonMetadataServices.CreatePropertyInfo(typeInfo.Options, new JsonPropertyInfoValues<string>
    {
        IsProperty = true,
        IsPublic = true,
        DeclaringType = typeInfo.Type,
        PropertyName = UnionCaseKinds.PropertyName,
        PropertyTypeInfo = (JsonTypeInfo<string>)typeInfo.Options.GetTypeInfo(typeof(string)),
        Getter = _ => kind,

        // Not read-only, so IgnoreReadOnlyProperties doesn't drop it. The value is only used by the classifier.
        Setter = (_, _) => { },
    });
    typeInfo.Properties.Insert(0, property);
};
```

**3. Reading: a type classifier** - the hook that error message asked for. It looks ahead for `"kind"` on a *copy* of the reader, so the real reader still starts at the beginning of the object, and returns the matching case type:

```csharp
return (ref Utf8JsonReader reader) =>
{
    // Look ahead on a copy, so the real reader still starts at the beginning of the object.
    var lookahead = reader;
    lookahead.Read();

    while (lookahead.TokenType == JsonTokenType.PropertyName)
    {
        if (lookahead.ValueTextEquals(UnionCaseKinds.PropertyName))
        {
            lookahead.Read();
            var kind = lookahead.GetString();
            return kind is not null && caseByKind.TryGetValue(kind, out var caseType)
                ? caseType
                : throw new JsonException($"Unknown {unionName} kind '{kind}'. Known kinds: {string.Join(", ", caseByKind.Keys)}.");
        }

        lookahead.Read();
        lookahead.Skip();
        lookahead.Read();
    }

    throw new JsonException($"{unionName} JSON has no \"{UnionCaseKinds.PropertyName}\" property.");
};
```

Now a turn log looks like this:

```json
{ "kind": "rollDice", "player": { "value": "alice" } }
{ "kind": "playCard", "player": { "value": "bob" }, "card": { "value": "forest-1" }, "target": { "x": 2, "y": 3 } }
{ "kind": "pass", "player": { "value": "bob" } }
```

### Is this better than a converter?

Honestly, it's not shorter: the workaround is 79 non-blank lines against the legacy converter's 63. Its advantages are elsewhere:

- **It's written once for any union.** A new case is one line in `UnionCaseKinds`, not a new branch in `Read` and `Write`.
- **System.Text.Json still handles every property.** Naming policies, enum handling, nested types, and source generation all keep working. A converter has to do all of that by hand.
- **Unknown kinds fail loudly.** The legacy converter turned a typo into an empty action. This one throws a `JsonException` that names the bad kind and lists the good ones.

If your union's cases have different shapes, skip all of this and use the structural classifier, like `Card`. And keep an eye on later .NET 11 updates - a built-in union discriminator would make this whole folder deletable.

## Customizing the Shape

The repo's options live on a source-generated context (more on that in a moment):

```csharp
[JsonSourceGenerationOptions(
    JsonSerializerDefaults.Web,
    GenerationMode = JsonSourceGenerationMode.Metadata,
    WriteIndented = true,
    IgnoreReadOnlyProperties = true,
    UseStringEnumConverter = true,
    TypeClassifiers = [typeof(UnionCaseKindClassifier)])]
[JsonSerializable(typeof(GameState))]
public sealed partial class TilesAndTokensJsonContext : JsonSerializerContext;
```

- **`JsonSerializerDefaults.Web`** gives camelCase names.
- **`UseStringEnumConverter`** writes `"terrain": "Water"` instead of `"terrain": 2`, which makes save files readable and safer when the enum is reordered.
- **`IgnoreReadOnlyProperties`** keeps computed members like `Player.IsEliminated` and `VictoryCondition.Name` out of the file. Those are derived from other data, so saving them would just be one more thing to keep in sync. (It's also why the modifier above needs a do-nothing setter - otherwise the `"kind"` property counts as read-only and gets dropped.)
- **The discriminator is `"kind"` everywhere**, for both the union workaround and `[JsonPolymorphic]`, instead of the default `"$type"`. One convention for the whole file.

### What happens with unknown cases?

They fail, and they fail clearly:

| What's in the file | Error |
|---|---|
| An unknown `GameAction` kind | *JsonException: Unknown GameAction kind 'tradeResources'. Known kinds: rollDice, playCard, pass.* |
| A `GameAction` with no `"kind"` | *JsonException: GameAction JSON has no "kind" property.* |
| An unknown `VictoryCondition` kind | *JsonException: Read unrecognized type discriminator id 'mostTerritory'. Path: $.victory* |

The first two messages are ours, from the classifier. The last one is built into `[JsonPolymorphic]`. Either way, an action or victory condition the code doesn't recognize can't load as a quietly wrong game.

## Source Generation, Trimming, and AOT

Unions and closed hierarchies both work with the System.Text.Json source generator. Saving and loading go through `TilesAndTokensJsonContext`, and the round-trip tests run through it too. Along the way, RC1 turned up a few things worth knowing:

- **The source generator validates unions at compile time.** Without a classifier, `GameAction` fails the build with *SYSLIB1227: Union type 'GameAction': case types 'RollDice', 'PlayCard', 'Pass' all serialize as JSON value type 'Object'. Set JsonUnionAttribute.TypeClassifier to provide a custom classifier that can disambiguate.* That's a nice touch: it's the same problem as the runtime error, caught earlier. A classifier added only at runtime doesn't satisfy it. Registering it in `[JsonSourceGenerationOptions(TypeClassifiers = [...])]` does, which is why `UnionCaseKindClassifier` has a parameterless constructor.
- **Use metadata mode if you use contract modifiers.** In the default generation mode, the generated fast path for a *collection* of unions skipped the `"kind"` property. Serializing an `IReadOnlyList<GameAction>` directly wrote no kinds at all, while the same list inside `GameState` did. `GenerationMode = JsonSourceGenerationMode.Metadata` fixed it.
- **Pick AOT-safe APIs in modifiers.** The obvious way to add a property, `JsonTypeInfo.CreateJsonPropertyInfo`, isn't trimming or AOT safe (IL2026, IL3050). `JsonMetadataServices.CreatePropertyInfo<T>` - what the source generator itself emits - is, and that's what the modifier uses.

The results:

- `TilesAndTokens.Serialization` builds with `IsAotCompatible=true` and **no warnings**.
- A **trimmed** self-contained publish of the demo has **0 trim warnings**, and the trimmed app saves, loads, and resumes correctly.
- **Native AOT** was verified later, on Linux, at the repo's [`native-aot` tag](https://github.com/NimblePros/tiles-and-tokens-net-11/tree/native-aot). The AOT compiler reported nothing from the domain or serialization code, and the native binary saved, loaded, and resumed a game with output identical to the JIT run. (The EF Core code from part 5 is another story. We'll dig into the whole Native AOT story in part 6.)

## Versioning a Save File

Save files outlive the code that wrote them. The repo treats that seriously: `samples/save-v1.json` was written by the first version of the serializer, it's committed, and it's never regenerated. A test makes sure it keeps loading:

```csharp
[Fact]
public void SaveV1_StillLoads()
{
    var state = GameStateSerializer.Load(Stream(SaveV1));

    state.TurnLog.Count.ShouldBe(10);
    state.Victory.ShouldBe(new MostPoints(Rounds: 20));
    state.Round.ShouldBe(6);
}
```

So what happens when we add an action later? The tests simulate a v2 `GameAction` with a trade case:

```csharp
public sealed record TradeResources(PlayerId Player, PlayerId With);

public union GameActionV2(RollDice, PlayCard, Pass, TradeResources);
```

with one extra line registering its kind:

```csharp
UnionCaseKinds.Default.With(typeof(TradeResources), "tradeResources")
```

- **Adding a case is backward compatible.** v2 loads the v1 turn log unchanged, and round-trips its new case.
- **Old code reading a new file fails clearly.** v1 reading a v2 save throws *"Unknown GameAction kind 'tradeResources'. Known kinds: rollDice, playCard, pass."* - not a silently empty action.

(The tests use a separate `GameActionV2` so the real game rules don't change. In a real app, you'd add the case to `GameAction` itself - and the compiler would hand you every switch from part 2 that needs to handle it.)

## Full Example: Save, Load, Resume

Here's the whole thing in one place. The `json` demo plays six turns, saves to `saves/demo.json`, shows parts of the file, loads it back, and keeps playing:

```shell
dotnet run --project src/TilesAndTokens.Demo -- json
```

```text
--- Play 6 turns ---
  -> alice placed Plains at (0,0) (+1 pt).
  -> bob rolled 1+2 (+3 pts).
  -> alice placed Forest at (1,0) (+1 pt).
  -> bob rolled 6+6 (+12 pts), doubles! Drew a card.
  -> alice played bonus-2 (+3 pts).
  -> bob rolled 6+1 (+7 pts).

--- Save to saves\demo.json ---
4,236 bytes. Two excerpts:

"victory" (closed hierarchy: [JsonPolymorphic] + [JsonDerivedType]):
    {
      "kind": "mostPoints",
      "rounds": 10
    }

"turnLog" (union: "kind" added by our modifier, read back by our classifier):
    {"kind":"playCard","player":{"value":"alice"},"card":{"value":"plains-5"},"target":{"x":0,"y":0}}
    {"kind":"rollDice","player":{"value":"bob"}}
    {"kind":"playCard","player":{"value":"alice"},"card":{"value":"forest-3"},"target":{"x":1,"y":0}}
    {"kind":"rollDice","player":{"value":"bob"}}
    {"kind":"playCard","player":{"value":"alice"},"card":{"value":"bonus-2"},"target":null}
    {"kind":"rollDice","player":{"value":"bob"}}

--- Load it back ---
  -> Round 4, alice to play, 6 turns in the log.
  -> Turn log cases: PlayCard, RollDice, PlayCard, RollDice, PlayCard, RollDice

--- Resume for 4 more turns ---
  -> alice rolled 5+6 (+11 pts).
  -> bob rolled 4+3 (+7 pts).
  -> alice rolled 6+2 (+8 pts).
  -> bob rolled 3+4 (+7 pts).
```

The serializer itself is small. Saving and loading are one line each:

```csharp
public static void Save(GameState state, Stream stream) =>
    JsonSerializer.Serialize(stream, state, GameStateTypeInfo);

public static GameState Load(Stream stream) =>
    JsonSerializer.Deserialize(stream, GameStateTypeInfo)
    ?? throw new JsonException("The save file contains null.");
```

And inside the save file, all three approaches sit side by side. Here are the first three cards in Bob's hand, a list of `Card` unions told apart by shape alone:

```json
"hand": [
  { "id": { "value": "bonus-4" }, "points": 3 },
  { "id": { "value": "plains-3" }, "terrain": "Plains" },
  { "id": { "value": "forest-1" }, "terrain": "Forest" }
]
```

The victory condition uses `[JsonPolymorphic]`, and the turn log uses the workaround's `"kind"`. The repo checks the exact shape of all of it against committed files in `tests/TilesAndTokens.Serialization.Tests/Expected/`, so any change to the save format shows up as a failing test, not a surprise in someone's save folder.

## Takeaway

System.Text.Json in .NET 11 is union-aware, and that awareness is real: it writes case values instead of wrapper structs, it has a new type classifier hook, and the source generator catches ambiguous unions at compile time. Here's how it shook out for Tiles & Tokens on RC1:

| Type | What it needed |
|---|---|
| `Card` (union, cases with different shapes) | **One attribute.** Fully native. |
| `VictoryCondition` (closed hierarchy) | **The same attributes as before C# 15.** No gain in code, but the subtype list is now guaranteed complete. |
| `GameAction` (union, cases with the same shape) | **A workaround** using a contract modifier and a type classifier. No converter, but not less code. |

So: **if your union's cases have different shapes, you're in great shape. If they don't, plan on a discriminator** - and watch for one to show up built in.

To try it yourself:

```shell
git checkout post-4
dotnet test
dotnet run --project src/TilesAndTokens.Demo -- json
```

Next up in part 5, we'll put saved games in a database with **EF Core 11**, index properties nested deep inside complex types, and see which of our C# 15 types EF Core can map - and which it can't yet.
