Updating Blazor Pac-Man to .NET 6 and C# 10

A while back I wrote a clone of Pac-Man in Blazor WebAssembly. This post is about updating it to use any useful features of .NET 6 and C# 10. If this kind of thing interests you, you might also like to read the post I did last year about upgrading it to .NET 5 and C# 9. If you just want skip all this, you can play it right now!

Like last time, this is a rather rambling post that describes what I did and whether each feature was useful or not, and isn't a definitive list of C# 10 / .NET 6 features, nor how best to use them!

The source code is on GitHub.


The first step was to change the Target Framework Moniker (TFM) from net5.0 to net6.0

  <PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<RazorLangVersion>3.0</RazorLangVersion>
</PropertyGroup>

Spoiler alert: just updating the TFM can be a breaking change! (read on...)

After the change, I built and ran the game, and ... nothing! The game was stuck on the loading screen with no exceptions or error messages.

I tried to bring up the developer tools in Edge, but Edge had hung! I couldn't even close it!

Time to step through with the debugger - here's the frame I ended up at from the stack trace:

while (_delta >= timestep) {
...
await update();
_delta -= timestep;

if (Debugger.IsAttached) {
oops=> _delta = timestep;
}
}

This is the main 'game loop' that continually runs the update & draw loop. If the last frame took longer than 16.66 milliseconds (for 60 frames per second), then this loop would catch up by continually doing another update before any more draws are done. But if the debugger is attached, we don't want this behaviour, because, if we stopped on a breakpoint for say, 30 seconds, when we resumed playing, it'd instantly run the game as quickly as it could to make up those 30 seconds before redrawing; in effect it'd instantly jump forward by those 30 seconds, and you'd likely end up being eaten 3 times and be back on the title screen!

As indicated by the oops=> line above, there is a bug which means that the while loop never finishes, and hence why Edge appeared to hang. The fix was to set _delta = 0.

This bug never surfaced in .NET 5 because Debugger.IsAttached was never true, but in .NET 6, it is now true if the debugger is attached.

The debugging experience for wasm is an ever-changing landscape and it looks like a lot of work has taken place since .NET 5. This document describes the current state of debugging wasm.

Anyway, now that it actually runs, let's get applying the new features!

file-scoped namespaces #

Instead of this:

namespace PacMan.GameComponents {
public class Game : IGame {
...

... we could just use

namespace PacMan.GameComponents;

public class Game : IGame {
...

ReSharper (R#) can help with this:

It didn't take R# long and this soon popped up:

Thankfully, we can revert via git, so I don't think I will open up those 183 files!

The next thing to apply is:

global usings and implicit global usings #

A using directive saves us from having to specify a type's full name over and over again. A global using in an evolution of this in that we don't have to specify the using over and over again. For example, there are lots of repeated usings like this:

using System;
using System.Diagnostics.CodeAnalysis;
using System.Drawing;
using System.Numerics;
using PacMan.GameComponents.Canvas;
using PacMan.GameComponents.Events;

Let's just pick one for now: I know that for the GameComponents project, we reference System in almost every file, so we can change that to a global using in any one of the files in the project:

global using System;
using System.Diagnostics.CodeAnalysis;

If we now look in another file, we can see that other instances of using System; are now redundant:

But we can take this further with implicit global usings:

By adding <ImplicitUsings>enable</ImplicitUsings> in the project, the .NET SDK build system generates these global usings for us. It's not enabled by default as it could introduce breaking changes.

So, let's turn it on and see if it breaks anything in Pac-Man!

After turning it on, things like using System; and using System.Threading.Tasks were greyed out. This is R# telling us they're redundant. By searching through the whole solution, we can see over 180 other redundancies with namespaces:

So, let's use R# to remove them all:

Let's look at one of the files that have been changed:

It decided that System, System.Collections.Generic, and System.Threading.Tasks were 'implicitly used'.

But if I want other namespaces to be implicit, I could add them in the project file like so:

<ItemGroup>
<Using Include="MediatR" />
<Using Include="PacMan.GameComponents.Audio" />
<Using Include="PacMan.GameComponents.GameActs" />
<Using Include="PacMan.GameComponents.Events" />
</ItemGroup>

๐Ÿค“ The compiler auto-generates a file (named, in our case, PacMan.GameComponents.GlobalUsings.g.cs) and turns those namespaces into global using statements. If a namespace doesn't exist, you'll get a compilation error.

Now I can see that those namespaces no longer have to be included:

There are quite a few other namespaces that I'd like to be 'global'. I have a choice of:

  1. making them global in an arbitrary .cs file
  2. making them global in the .csproj file
  3. making them global in a specific GlobalUsings.cs

Assessing each option:

1 - just seems a bit random - you're declaring something global but in a specific file
2 - there's no intellisense in the .csproj file when adding these, and refactoring doesn't update it (yet, anyway)
3 - seems 'just right' - the name (GlobalUsings.cs) tells us what it is, there's intellisense as we type, and refactoring namespace names updates it

So I left implicit global usings as-is and moved all of my global usings into GlobalUsings.cs

Caller argument expressions #

This page says that this features gives your method the expression used as an argument, e.g.

public static void ValidateArgument(string parameterName,
bool condition,
[CallerArgumentExpression("condition")] string? message=null) {
if (!condition) {
throw new ArgumentException(
$"Argument failed validation: <{message}>",
parameterName);
}
}

... and you use that method like this:

public void Operation(Action func) {
Utilities.ValidateArgument(nameof(func), func is not null);
func();
}

... which gives you

Argument failed validation: <func is not null>

I like this feature but I can't see much use for it in a standard application. The page linked above does say (emphasis mine):

Diagnostic libraries may want to provide more details about the expressions passed to arguments

I'm OK that the API of these diagnostic libraries have these additional null default parameters, although I wouldn't like to see them everywhere in every application.

The article also said that they're written by the compiler if not provided by the user, so performance overhead shouldn't be a problem. A quick benchmark didn't show significant differences:

MethodMeanErrorStdDevRatioRatioSDGen 0Gen 1Gen 2Allocated
NotPassingAnything26.11 ns3.653 ns0.200 ns1.000.000.0091--152 B
PassingAMessage25.73 ns6.866 ns0.376 ns0.990.020.0091--152 B

Record Structs #

In C# 9 we could have record class Name(string First, string Last) and the compiler would generate equality and hashcode related code. I used a few of them when I updated Pac-Man to .NET 5 last year.

In C# 10 we can now have things like record struct Score(int Value). This again generates boilerplate code for equality etc. structs are used a lot more in Pac-Man, as, being a game, we want to limit the amount of heap allocations so that the game runs smoothly and isn't interrupted by lengthy garbage collections.

For a long time, I've wanted to use 'Value Objects' in Pac-Man. Value Objects are strongly-typed immutable objects that represent 'domain concepts', e.g. instead of int score, we'd have Score score. This was possible before, but the approaches that I've used in the past weren't suitable for Pac-Man as they incurred performance penalties which were prohibitive in a game - namely the overhead of heap allocations. Record Structs takes us some of the way there. For instance, in Pac-Man, the concept of a 'Score' is represented by an int:

~๐Ÿ’พPlayerStats.cs:~

public int Score { get; private set; }

You may be thinking that's obvious and of course an int is a perfectly good way of representing a Score. But read on...

A Score is used like this in the game: Score += 10. Nowhere in the game do we decrement a Score. We reset it to zero at the start of a game, but it's never decremented. The concept of 'Incremented only' isn't represented in the code because an int is just an int, and ints can be decremented, reset to zero, and can even be negative (granted, we could use uint to handle this particular scenario).

What we really need is something like this:

public record struct Score(int Value)
{
public void IncreaseBy(uint points) => Value += (int)points;

public static readonly Score Zero = new(0);

public static implicit operator int(Score score) => score.Value;
}

The implicit conversion is so that it can be treated as an int, e.g. new Score(10) == 10; // true

We've now captured the intent that scores can only be incremented. It also makes the code clearer that we're dealing with a Score just from the name of type.

There's other places where we could use record structs. For instance, when storing information about a level, there are many properties that are primitives:

public record LevelProps(
IntroCutScene CutScene,
FruitItem Fruit1,
int FruitPoints,
float PacManSpeedPc,
float PacManDotsSpeedPc,
float GhostSpeedPc,
float GhostTunnelSpeedPc,
int Elroy1DotsLeft,
float Elroy1SpeedPc,
int Elroy2DotsLeft,
float Elroy2SpeedPc,
float FrightPacManSpeedPc,
float FrightPacManDotSpeedPc,
float FrightGhostSpeedPc,
int FrightGhostTime,
int FrightGhostFlashes);

It would be nice to treat these as 'domain' concepts too, and use a Value Object. The concepts here are:

So, let's take the concept of 'Points': it's currently represented as an int. The problem with this is that anywhere that expects 'Points' could be given anything that is an int (e.g. how many dots left, a score, etc.). The compiler won't tell us about this because an int is an int. record structs could help here, but I want to validate that nobody could ever (ever) create Points with a value <= 0. With a record struct, we could add a validation method in the constructor, but we lose the nice succinct syntax (primary constructors) for creating one. By losing the primary constructor, we end up with a normal constructor which has the validation logic:

public record struct Points {
public Points(int value) {
if (value <= 0) throw new InvalidOperationException("Points must be a positive value");
Value = value;
}

public int Value { get; }
}

Also, we haven't satisfied the requirement of never having Points with a value of 0; there's nothing stopping us accidentally creating points using Points points = default(Points);. We'd get points.Value; // 0!! as shown in this unit test:

var points = default(Points);
points.Value.Should().NotBe(0); // oops

We could also mistakenly call the default constructor:

var points2 = new Points();
points2.Value.Should().NotBe(0); // oops

It'd be quite difficult to stop this, plus, it means we still have to check that these type are valid everywhere they're used. We need some kind of mechanism where there are stronger guarantees of validity...

Enter... Vogen #

To help with this, I recently created a NuGet package named Vogen which is a source generator and analyser. It augments types that are decorated with a ValueObject attribute. By using it, we can switch our Points type to look like this:

[ValueObject(typeof(int))]
public readonly partial struct Points
{
private static Validation Validate(int value) =>
value > 0 ? Validation.Ok : Validation.Invalid("Points must be a positive value");
}

.. and now we can't accidentally misuse it:

new Points(0); // error CS1729: 'Points' does not contain a constructor that takes 1 arguments
var points = default(Points); // error VOG009: Type 'Points' cannot be constructed with default as it is prohibited.

The VOG009 error is produced by the code analyser in Vogen.

So, our level properties type from above now looks like this:

public record LevelProps(
Points FruitPoints,
...
SpeedPercentage PacManSpeedPc,
SpeedPercentage FrightGhostSpeedPc)

The goal of Vogen was to allow the use Value Objects with the same speed as primitives, and to pretty much guarantee validity. The source generator generates code to handle the speed aspect, and the code analyser spots situations where validity might be bypassed. Feel free to check out Vogen and leave any feedback!

Interpolated string handlers #

Back to new features: this feature allows you to write a handler for the placeholder expressions used in an interpolated string. This will be most useful for logging libraries, where they can provide overloads taking an interpolated string handler, e.g.

public void LogMessage(
LogLevel level,
[InterpolatedStringHandlerArgument("", "level")] LogInterpolatedStringHandler handler) {
if (EnabledLevel < level) return;
Console.WriteLine(handler.GetFormattedText());
}

The beauty of this (excluding the internal ugliness of attributes and duck typing of the handlers' methods) is that callers still call you with a string:

logger.LogMessage(LogLevel.Trace, $"none of will get evaluated {DateTime.Now}");

If the constructor of the handler determines that interpolation is not required, then none of it will be evaluated. Here's an excellent tutorial that goes into depth about this feature.

Types decorated with with the [InterpolatedStringHandler] attribute are used by the language compiler, hence they're in the System.Runtime.CompilerServices namespace.

In the .NET runtime, the type DefaultInterpolatedStringHandler has this attribute, and is declared as:

[InterpolatedStringHandler]
public ref struct DefaultInterpolatedStringHandler

This is currently used for Debug.Assert (#) where the allocation of the string and the interpolated expressions will only take place if the condition is true.

It's not currently used in logging, but likely will be soon.

Pac-Man doesn't do any logging (there's a diagnostics screen available if you run in Debug and press D while playing), so there's no use for it here.

Issues deploying #

After adding all this, I deployed the game to Azure and it ran fine. However, on the CDN I have set-up, I was getting an error:

blazor.webassembly.js:1
Error: Could not find class: Microsoft.AspNetCore.Components.WebAssembly.Hosting:EntrypointInvoker
in assembly Microsoft.AspNetCore.Components.WebAssembly
at Object.resolve_method_fqn (dotnet.6.0.0.1tqe9564q6.js:1)
...
at a (blazor.webassembly.js:1)

I found out, from this page that EntrypointInvoker was has been removed in .NET 6. So, it was still loading some old files. I confirmed this by looking at the Developer Tools. The workaround is to delete your cache (or 'Disable Cache' in Developer Tools on Edge/Chrome).
A better fix is to vary the URL used by adding a version (any version) to the, e.g.

<script src="_framework/blazor.webassembly.js?version=6"></script>

Conclusion #

Pac-Man, after using these new features, now has a lot less 'ceremony' code thanks to global usings and file-scoped namespaces. I didn't use any of the preview features of C# 10, such as Generic Math, but I have looked at them previously.

Overall, the language is improving. I'd still like to see more focus on better immutability (as I mentioned in this post about readonly parameters). I'd also like to see built-in language constructs similar to what we have in Vogen that guarantee validation.

๐Ÿ™๐Ÿ™๐Ÿ™

Since you've made it this far, sharing this article on your favorite social media network would be highly appreciated ๐Ÿ’–! For feedback, please ๐Ÿฆ‹ ping me on Bluesky! ๐Ÿฆ‹

Leave a comment

Comments are moderated, so there may be a short delays before you see it.

Published