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

October 22, 2026Saving the Game: System.Text.Json and Union State
Sarah Dutkiewicz

Sarah Dutkiewicz, Senior Trainer

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, we modeled actions, outcomes, and cards as unions. In part 3, 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 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:

[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? It isn’t in the list. Nothing tells you so, until you try to save a game that contains one:

[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:

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.

ValueJSON writtenReads 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:

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:

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:

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

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

[
  { "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:

[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
[JsonDerivedType(typeof(MostPoints), "mostPoints")]
[JsonDerivedType(typeof(FirstToEdge), "firstToEdge")]
[JsonDerivedType(typeof(LastPlayerStanding), "lastPlayerStanding")]
public closed record VictoryCondition
{
    public abstract string Name { get; }
}
{ "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:

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:

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:

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:

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:

{ "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):

[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 fileError
An unknown GameAction kindJsonException: Unknown GameAction kind ’tradeResources’. Known kinds: rollDice, playCard, pass.
A GameAction with no "kind"JsonException: GameAction JSON has no “kind” property.
An unknown VictoryCondition kindJsonException: 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. 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:

[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:

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

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

with one extra line registering its kind:

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:

dotnet run --project src/TilesAndTokens.Demo -- json
--- 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:

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:

"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:

TypeWhat 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:

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.