Rolling Into .NET 11: Modeling a Board Game with C# 15

October 13, 2026Rolling Into .NET 11: Modeling a Board Game with C# 15
Sarah Dutkiewicz

Sarah Dutkiewicz, Senior Trainer

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

It’s your turn. You can roll the dice, play a card, or pass. You pick one, and the game moves on.

Now, how does your code know which one happened?

That sounds like a simple question. In most C# codebases, the answer is “it depends on which property is set,” or “check the string,” or “cast it and hope.” All three work - right up until someone adds a new kind of turn, mistypes a string, or sets two properties at once. Then the compiler shrugs, the build stays green, and the bug shows up at runtime.

C# 15 and .NET 11 bring new tools for exactly this problem: unions, closed hierarchies, union-aware System.Text.Json, and EF Core 11 indexes on nested complex types. Over this series, we’ll use a small board game to see what each one fixes, and where each one still has rough edges. Then, in a bonus round, we’ll see how much of it survives a Native AOT publish.

But first, let’s look at the “before” picture.

The Pain Today: Three Ways to Model a Turn

Every code sample in this series comes from the companion repo, NimblePros/tiles-and-tokens-net-11. The code for this post is at the post-1 tag, in src/TilesAndTokens.Legacy. That project is deliberately pinned to C# 14, so none of the new features can sneak in.

A turn is one of three actions:

  • Roll dice - roll 2d6 and add the total to your score. Doubles draw a bonus card.
  • Play a card - play a card from your hand. Tile cards need a target position.
  • Pass - do nothing.

Here are three ways I’ve seen (and, honestly, written) that model.

Anti-pattern 1: String flags

A string says which action this is, and the other properties may or may not be filled in depending on that string.

public sealed class StringTurn
{
    public string PlayerId { get; set; } = "";

    /// <summary>"RollDice", "PlayCard", or "Pass". Hopefully.</summary>
    public string ActionType { get; set; } = "";

    /// <summary>Only for "RollDice". Only the total is stored, so doubles are lost.</summary>
    public int? DiceTotal { get; set; }

    /// <summary>Only for "PlayCard".</summary>
    public string? CardId { get; set; }

    /// <summary>Only for "PlayCard" with a tile card.</summary>
    public Position? Target { get; set; }
}

The processor switches on the string:

public static string Apply(LegacyGame game, StringTurn turn)
{
    switch (turn.ActionType)
    {
        case "RollDice":
            return game.RollDice(turn.PlayerId, turn.DiceTotal!.Value, isDoubles: false);

        case "PlayCard":
            return game.PlayCard(turn.PlayerId, turn.CardId!, turn.Target);

        case "Pass":
            return game.Pass(turn.PlayerId);

        default:
            // Nothing matched. No error, no state change: the turn just vanishes.
            return string.Empty;
    }
}

Count the problems:

  • Typos compile. ActionType = "Rolldice" builds fine. The default branch swallows it, and the turn silently disappears.
  • The data depends on the string. DiceTotal only matters when ActionType is "RollDice". Forget to set it, and the ! turns into an InvalidOperationException at runtime.
  • Information gets lost. Only the total is stored, so the processor can’t tell whether the roll was doubles. The bonus card draw never happens. This wasn’t planned - it fell out of the shape of the class.

The repo has a test that proves the typo compiles and does nothing:

[Fact]
public void TypoInActionType_CompilesAndSilentlyDoesNothing()
{
    var game = GameFixture.NewGame();

    // "Rolldice" instead of "RollDice". The compiler has no idea.
    var message = StringTurnProcessor.Apply(game, new StringTurn { PlayerId = "alice", ActionType = "Rolldice", DiceTotal = 7 });

    message.ShouldBeEmpty();
    game.Player("alice").Score.ShouldBe(0);
    game.Player("alice").ConsecutivePasses.ShouldBe(0);
}

Anti-pattern 2: The null-heavy wrapper

Let’s get rid of the string. Instead, give each kind of action its own nullable property, and agree that exactly one gets set.

public sealed class TurnAction
{
    public string PlayerId { get; set; } = "";

    public DiceRoll? Roll { get; set; }

    public Card? Card { get; set; }

    public Position? Target { get; set; }

    public bool IsPass { get; set; }
}

“Exactly one gets set” is a promise, not a rule. Nothing stops this:

var invalid = new TurnAction
{
    PlayerId = "alice",
    Roll = new DiceRoll(3, 4),
    Card = GameFixture.ForestCard,
    Target = new Position(0, 0),
};

Rolling dice and playing a card in one turn is against the rules, but this object is perfectly happy to represent it. So what does the processor do with it?

public static string Apply(LegacyGame game, TurnAction action)
{
    if (action.IsPass)
    {
        return game.Pass(action.PlayerId);
    }

    if (action.Roll is { } roll)
    {
        return game.RollDice(action.PlayerId, roll.Total, roll.IsDoubles);
    }

    if (action.Card is { } card)
    {
        return game.PlayCard(action.PlayerId, card.Id, action.Target);
    }

    throw new InvalidOperationException("TurnAction has no action set.");
}

The order of the if checks quietly decides the answer. The roll wins, and the card is silently ignored. Reorder those checks during a refactor, and the behavior changes with no compiler warning. And a TurnAction with nothing set? It compiles, then throws.

This pattern is everywhere: think of any DTO with Error, Value, and IsSuccess all on one class. That’s the same shape. The repo’s hand-rolled Result<T> has the same flaw. You’re supposed to check IsSuccess before reading Value, but the compiler can’t make you.

Anti-pattern 3: Base class plus casting

Now let’s use the type system. An abstract base class, one subclass per action:

public abstract class LegacyAction(string playerId)
{
    public string PlayerId { get; } = playerId;
}

public sealed class RollDiceAction(string playerId, DiceRoll roll) : LegacyAction(playerId) { ... }
public sealed class PlayCardAction(string playerId, string cardId, Position? target) : LegacyAction(playerId) { ... }
public sealed class PassAction(string playerId) : LegacyAction(playerId);

Invalid combinations are gone - a RollDiceAction can’t also carry a card. That’s real progress. Then the processor:

public static string Apply(LegacyGame game, LegacyAction action)
{
    if (action is RollDiceAction)
    {
        var rollAction = (RollDiceAction)action;
        return game.RollDice(rollAction.PlayerId, rollAction.Roll.Total, rollAction.Roll.IsDoubles);
    }
    else if (action is PlayCardAction)
    {
        var cardAction = (PlayCardAction)action;
        return game.PlayCard(cardAction.PlayerId, cardAction.CardId, cardAction.Target);
    }
    else if (action is PassAction)
    {
        return game.Pass(action.PlayerId);
    }
    else
    {
        // The only safety net is a runtime exception.
        throw new NotSupportedException($"Unknown action type: {action.GetType().Name}");
    }
}

Yes, you could tidy this up with a switch expression and type patterns - and in modern C#, you should. But it doesn’t fix the real problem: the base class is open. Anyone, in any assembly, can add a subclass. Six months later, someone adds trading:

public sealed class TradeAction(string playerId, string otherPlayerId, string giveCardId, string takeCardId)
    : LegacyAction(playerId) { ... }

It compiles. Nothing points out that LegacyActionProcessor doesn’t handle it. Even with a switch expression, the compiler can’t know the list of subclasses is complete, so you still need that discard arm, and it still throws at runtime.

See all three fail

The demo app runs each anti-pattern and narrates what goes wrong. From the repo root, at the post-1 tag:

dotnet run --project src/TilesAndTokens.Demo -- legacy
--- 1. String flags ---
    new StringTurn { ActionType = "RollDice", DiceTotal = 7 }
  -> alice rolled 7 (+7 pts).
    new StringTurn { ActionType = "Rolldice", DiceTotal = 7 }   // typo
  !! Compiled fine. Result: "". alice's score is still 7. The turn vanished.

--- 2. Null-heavy wrapper ---
    new TurnAction { Roll = new DiceRoll(3, 4), Card = forestCard, Target = (0,0) }
  -> alice rolled 7 (+7 pts).
  !! Rolling AND playing a card is illegal, but it compiled. The card was silently ignored (0 tiles on the board).

--- 3. Base class + casting ---
    LegacyActionProcessor.Apply(game, new PassAction("bob"))
  -> bob passed.
    LegacyActionProcessor.Apply(game, new TradeAction(...))   // added later
  !! Compiled fine, then at runtime: NotSupportedException: Unknown action type: TradeAction

--- 4. Hand-rolled Result<T> ---
    var result = LegacyPlacementValidator.Validate(board, tileOnOccupiedCell);
  -> IsSuccess = False, Error = "(3,3) is already occupied."
    result.Value   // nobody checked IsSuccess
  !! Compiled fine, then at runtime: Cannot read Value of a failed result: (3,3) is already occupied.

Notice the common thread: “Compiled fine.” Every one of these bugs gets past the compiler. The tests in tests/TilesAndTokens.Legacy.Tests pin each weakness down, so later posts can show the same scenarios turning into build errors.

Meet Tiles & Tokens

To give the type system something real to model, the series uses a deliberately small board game called Tiles & Tokens. The full rules fit on one page:

  • 2-4 players share an 8×8 grid. Each cell is empty or holds one tile. A tile has a terrain (Plains, Forest, Water, Mountain) and an owner.
  • Each player starts with 3 cards. A card either places a specific terrain tile or is a special card, like bonus points.
  • On your turn, you take exactly one action: roll dice, play a card, or pass. Pass three turns in a row, and you’re eliminated.
  • Placed tiles must follow the placement rules: the cell must be on the board and empty, each new tile must touch one of your own tiles (after your first), and Water can’t touch Mountain.
  • A victory condition is chosen at setup: most points after N rounds, first to connect opposite edges of the board, or last player standing.

Here’s how one turn flows through the game:

  flowchart LR
    T[Player's turn] --> A{Action}
    A -->|Roll dice| S[Add to score]
    A -->|Play card| P{Placement valid?}
    A -->|Pass| E[Count passes]
    P -->|Placed| S
    P -->|Rejected or blocked: choose again| A
    S --> V{Victory check}
    E --> V
    V -->|No winner| T
    V -->|Winner| W[Game over]
  

Each box in that diagram is a modeling question. An action is exactly one of three things. A placement result is placed, rejected for a reason, or blocked. A victory condition is one of a known set. These are the shapes the new features are built for.

What’s New in .NET 11 and C# 15

A quick lay of the land before we dive in:

  • Release timing: .NET 11 is scheduled to ship on November 10, 2026. As I write this, the latest bits are Release Candidate 1 (SDK 11.0.100-rc.1.26425.128). Everything in this series was verified against RC1, and the companion repo will be re-verified at GA.
  • Support: .NET 11 is a Standard Term Support (STS) release, supported for 24 months. If you need the longest runway, .NET 10 is the current LTS. STS releases are a great way to try new features and plan your next LTS upgrade, which is part of why we’re looking at them now.
  • Language version: projects targeting net11.0 get C# 15 by default. You don’t need LangVersion set to preview to use unions or closed hierarchies on RC1. The repo still sets <LangVersion>15</LangVersion> explicitly, and pins the “before” project to <LangVersion>14</LangVersion>, so there’s no doubt which features each project can use.

Tip: Not sure which language version a project is really compiling with? Add #error version to any source file and build. The CS8304 error reports both the compiler version and the effective language version.

Series Roadmap

Here’s where we’re headed. Each post picks up a piece of the game that the anti-patterns above got wrong.

Part 2: Unions - “a turn is exactly one of these.” C# 15 adds a union declaration. A GameAction is a roll, a card play, or a pass, and nothing else - no strings, no nullable grab bag:

public union GameAction(RollDice, PlayCard, Pass);

Miss a case in a switch expression, and the build fails.

Part 3: Closed hierarchies - “the compiler knows every victory condition.” A closed base type can only be derived from in its own assembly, so the compiler can check that a switch covers every subtype:

public closed record VictoryCondition
{
    public abstract string Name { get; }
}

That TradeAction surprise from anti-pattern 3? With a closed hierarchy, it’s a build error.

Part 4: System.Text.Json - “save and load mid-game.” System.Text.Json in .NET 11 knows about unions. We’ll save a game, turn log and all, and load it back. We’ll also see what works out of the box, what needs one attribute, and what still needs a little help.

Part 5: EF Core 11 - “query saved games by what’s on the board.” EF Core 11 can index properties nested inside complex types:

game.HasIndex(g => g.Summary.Leader.PlayerId);

We’ll store saved games in Postgres (run locally with Aspire), and look honestly at where the RC1 bits shine and where they stop.

Part 6: Native AOT - “the bonus round.” This one showed up while building the demo. We’ll publish the whole app ahead of time and see which parts of the series compile cleanly to a native binary, and which ones don’t:

dotnet publish src/TilesAndTokens.Demo -c Release -r linux-x64 -p:PublishAot=true

Setup

If you’d like to follow along:

  1. Install the .NET 11 SDK. Grab the latest .NET 11 SDK from the .NET download page. The repo was built on 11.0.100-rc.1.26425.128 and will roll forward to later .NET 11 feature bands. Confirm what you have with:

    dotnet --list-sdks
    
  2. Clone the companion repo and check out this post’s tag:

    git clone https://github.com/NimblePros/tiles-and-tokens-net-11.git
    cd tiles-and-tokens-net-11
    git checkout post-1
    
  3. Build, test, and run the demo:

    dotnet build
    dotnet test
    dotnet run --project src/TilesAndTokens.Demo -- legacy
    

A few repo choices worth knowing about:

  • global.json pins the SDK and opts dotnet test into Microsoft.Testing.Platform, which xUnit v3 needs on the .NET 10+ SDK:

    {
      "sdk": {
        "version": "11.0.100-rc.1.26425.128",
        "rollForward": "latestFeature",
        "allowPrerelease": true
      },
      "test": {
        "runner": "Microsoft.Testing.Platform"
      }
    }
    
  • TreatWarningsAsErrors is on everywhere. That’s on purpose. Missing switch cases are reported as warnings in C#, and we want them to fail the build.

  • Tests use xUnit v3 with Shouldly for assertions and NSubstitute for fakes.

  • Each post has a git tag (post-1 through post-5, plus native-aot for part 6), so you can check out the code as it was at the end of any post.

Wrap-Up

String flags, nullable wrappers, and open base classes all share one flaw: the rules of the domain live in comments and conventions, and the compiler can’t see them. Every bug in this post said “compiled fine.”

Over the rest of this series, we’ll move those rules into the type system, one feature at a time. And remember - no board game experience required. If you’ve ever written an if chain over a type, or a class with five nullable properties and a comment saying “only one of these should be set,” you already know the game.

Next up in Part 2, we’ll replace all three turn models with a single C# 15 union and watch the compiler start catching our mistakes.