Updating Blazor PacMan to .NET 5

A while back I wrote a clone of PacMan in Blazor WebAssembly and this post is about updating it to .NET 5. If you only recognised the word PacMan (and possibly Updating) in that sentence, then you won't find much of interest here, so skip this nonsense and just go and play it!

Just so you know, this is a rather rambling post that describes what I did, and isn't a definitive list of C# 9 / .NET 5 features, nor how best to use them!

The code for the .NET 5 branch is on GitHub.


The first step was to change the Target Framework Moniker from netstandard 2.1 to net5.0

The next step was to change the SDK (David's blog post saved me hours of head-scratching!)

Then update the other packages:

The microsoft.aspnetcore.components.webassembly.build package is no longer needed, so remove that.


NOTE

Strangely, even with Visual Studio 16.8.1 and 16.9.0 Preview 1.0, if you do a new Blazor webassembly project, you still get the old SDK target and still get the Build package. This may be because of outdated templates:


After doing this, I got various build errors. So I did a clean and rebuild of the solution. I assume these errors resulted from out of date assets from a previous version of the framework.

A quick run shows that it's working as expected!

Now on to some of the C# 9 features.

The first one is records, which I've been excited about for a long time! (yeah, I know...)

Records #

records are immutable classes. The compiler generates equality and copy operations. Properties have the new init accessibility so that they can only be set during object initialisation. The following is a record:

record Person(string Name, int Age);

You can see all that the compiler generates using the super Sharplab. It generates quite a lot.

One thing that initially surprised me is that 'properties have init setters', e.g. you can do this:

Person p = new("fred", 42) {Name = "john", Age = 50};

I don't know why you'd want to do that, but the fact that you can shows that properties' setters are init and not auto properties.

There's a few classes in PacMan that are just POCOs with no behaviour that I want to convert to records, e.g.

Notice the horrendous constructor. This is just so that the type remains immutable (i.e. readonly properties).
Notice the even more horrendous code that creates all of the levelprops:

I initially wanted to just use the new init setters for the properties so that I could use object initialisation rather than a constructor. Although that would get rid of the constructor, it would introduce the chance of forgetting to set a property. So I scrapped that idea and went with just making it a record.

Before I did that though, I was wondering why ReSharper had greyed out the LevelProps in new LevelProps(…).

It's because R# is suggesting that the type specification is redundant. In C# 9, you don't have to specify the type after new any more:

Let's do what it suggests and see what it looks like:

Slightly better, but still ugly, but now much less ceremony.

So, LevelProps is now a record. This is the entirety of that type!

As of writing this, there's no Refactoring to convert a class to a record.

Another type that I converted was:

public class CanvasTextFormat
{
public CanvasTextFormat(string fontFamily, int fontSize)
{
FontFamily = fontFamily;
FontSize = fontSize;

// ReSharper disable once HeapView.BoxingAllocation
FormattedString = $"{fontSize}px {fontFamily}";
}

public string FontFamily { get; }

public int FontSize { get; }

public string FormattedString { get; }
}

The interesting thing with this one is that it has a synthetic field (a field synthesised from other fields). Basically, for this one, I want the brevity that records provide, but I also want to set this other field. I wondered if I could do this in a record... No, was the answer; you can use the positional way of creating objects (with constructors), or the nominal way (with object initialisation).

I couldn't have this:

public record CanvasTextFormat(string FontFamily, int FontSize)
{
public CanvasTextFormat()
{
FormattedString = $"{FontSize}px {FontFamily}";
}

public string FormattedString { get; init; }
}

or this:

public record CanvasTextFormat(
string FontFamily,
int FontSize,
string FormattedString = $"{FontSize}px {FontFamily}");

But I could do this:

public record CanvasTextFormat(string FontFamily, int FontSize)
{
public readonly string FormattedString = $"{FontSize}px {FontFamily}";
}

That'll do!

I then went on to other classes that could be records, but due to the liberal use of structs in the game (to avoid GC pressure), there wasn't very many that could be converted.

Before I finished with Records, I took a quick peek at the IL. I don't normally do this becuase 1) I don't know what I'm looking at, and 2) I don't really have a lot of interest in it. I noticed this:

PrintMembers? I don't remember seeing that before or reading about it (of course, I just Googled it and it's everywhere!). It's protected (or private if the class is sealed), so I thought 'what's the point if you can't access it? Indeed, what's the point of printing all the members?'. Turns out, the default generated ToString calls PrintMembers:

If you override ToString, it doesn't

The signature of PrintMembers is:

protected virtual bool PrintMembers(StringBuilder builder)

The default (generated) implementation of ToString calls this after creating a StringBuilder to give it. But what's the bool for? The answer to that is that it says whether or not to insert a trailing space and comma. Exciting!

Target Typed New #

Another area where I found that 'target typed new' was useful was in this switch expression:

static ValueTask<CellIndex> _getChaseTargetCell()
{
var random = Pnrg.Value;

CellIndex cell = (random % 4) switch
{
0 => new CellIndex((int)MazeBounds.TopLeft.X, (int)MazeBounds.TopLeft.Y),
1 => new CellIndex(MazeBounds.Dimensions.Width, 0),
2 => new CellIndex(MazeBounds.Dimensions.Width, MazeBounds.Dimensions.Height),
_ => new CellIndex(0, MazeBounds.Dimensions.Height)
};

return new ValueTask<CellIndex>(cell);
}

... becomes ...

static ValueTask<CellIndex> _getChaseTargetCell()
{
var random = Pnrg.Value;

CellIndex cell = (random % 4) switch
{
0 => new((int)MazeBounds.TopLeft.X, (int)MazeBounds.TopLeft.Y),
1 => new(MazeBounds.Dimensions.Width, 0),
2 => new(MazeBounds.Dimensions.Width, MazeBounds.Dimensions.Height),
_ => new(0, MazeBounds.Dimensions.Height)
};

return new ValueTask<CellIndex>(cell);
}

R# told me I had over 80 opportunities to use this new format, and, well, seeing as it's new, why not!

Overall, I like this, although there's some strange looking syntax going on (at least it looks strange for now), e.g.

readonly List<Directions> _availableDirections = new(4);

(that's a list with an initial size of 4 (so that we don't end up with a sparse list wasting resources))

Logical Patterns #

The other new thing in C# 9 is the ability to simplify checks for multiple constant values.

In the game, we want to see what 'mode' the ghosts are in; chasing (chasing pac) or scattering (evading pac)
(as an aside, the other movement modes are 'going to house' (when they're just eyes of the ghosts), 'in house', and 'frightened' (when they're blue)). So to see if they're chasing or scattering:

if (MovementMode == GhostMovementMode.Chase || MovementMode == GhostMovementMode.Scatter)

... this can now become ...

if (MovementMode is GhostMovementMode.Chase or GhostMovementMode.Scatter)

I find this much nicer to read...

Similarly, this:

if (currentDirection == Down || currentDirection == Up)

... is much better written as:

if (currentDirection is Down or Up)

One other place where it made the code nicer to read was:

var isScatterOrChase = MovementMode == GhostMovementMode.Undecided
|| MovementMode == GhostMovementMode.Chase
|| MovementMode == GhostMovementMode.Scatter;

... now ...

var isScatterOrChase = MovementMode
is GhostMovementMode.Undecided
or GhostMovementMode.Chase
or GhostMovementMode.Scatter;

I flew through the code and really enjoyed converting all this stuff (well, what better way to spend a Saturday evening!). That was, until I tripped up on this:

public static bool IsSpecialIntersection(CellIndex cell) =>
cell is _specialIntersections[0]
or _specialIntersections[1]
or _specialIntersections[2]
or _specialIntersections[3];

... Computer Says No ... Boo.

Yes, this only works for constant values.

The && expression is also included. This code:

if (LevelNumber >= 1 && LevelNumber <= 3)

... becomes

if (LevelNumber is >= 1 and <= 3)

The last one for this is not. The code that we changed earlier:

if (!(MovementMode is GhostMovementMode.Chase or GhostMovementMode.Scatter))

.. can be expressed as

if (MovementMode is not (GhostMovementMode.Chase or GhostMovementMode.Scatter))

Notice that paranthesis is important. Without them, we'd get the wrong result. This tests fails

mode = GhostMovementMode.Scatter;
result = mode is not GhostMovementMode.Chase or GhostMovementMode.Scatter;
result.Should().BeFalse();

Static anonymous functions #

Callers of methods that take a Func<T1, T2... create anonymous functions. Sometimes, these anonymous functions are not cheap to create because they can create heap allocations if the lambda captures local variables, arguments, or instance state. Here, Anthony Giretti provides a nice concise description of the feature and the problem that it solves; the example he uses is:

// text is captured that can cause unexpected retention or unexpected additional allocation
PromoteCountry(country => string.Format(this._text, country));

... with the new static anonymous functions:

PromoteCountry(static country => string.Format(text, country)); // text is not captured

Interestingly, in Anthony's post, he links to another blog post on when to use local functions. I found this post interesting because I don't tend to local functions much, but the post described how they can help avoid closures.

I didn't have any places where I could use this in the game, but I mentioned it here because of the useful links.

Top level statements #


UPDATE

Top Level Statements don't appear to work in Blazor. A bug report has been filed. The exception says:

Cannot wait on monitors on this runtime.

https://github.com/dotnet/aspnetcore/issues/28677


Gone are the days when you had to have a Main method in an exe. Previously, the game had this:

using [lots of things!];

namespace PacMan
{
public class Program
{
public static async Task Main(string[] args)
{
var builder = WebAssemblyHostBuilder.CreateDefault(args);
...

But now we can get rid of that ceremony:

using [lots of things!];

var builder = WebAssemblyHostBuilder.CreateDefault(args);
// ... rest of the code that used to be in the Main method

However, for this Blazor app, there was now a compilation error:

The solution here was to just pick another type to identify the 'app assembly', e.g. App:

<Router AppAssembly="@typeof(App).Assembly">

Other things #

Below is some unrelated discoveries that I made while converting the game and writing this blog post.

Extension Deconstruct #

In C# 9 there's 'Extension GetEnumerator'. This basically allows you to add extension methods on types so that you can foreach over them. But something I only recently discovered, that has been around for a while, is that there's also an 'Extension Deconstruct'.
It allows you to add an extension method to deconstruct other types. We can use this in Pac-Man...

In the code we have cells which are represented as a type (struct) named CellIndex. Each cell is an 8 pixel by 8 pixel area of the screen and has an x and y position (integers). The playable area of the screen is comprised of 28x30 cells.
For the sprites (ghosts, fruit, Pac-Man etc.), they use pixel positions represented as a Vector2 which has an x and y position (floats).

It is useful to convert from pixels to a cell, so there's code like this:

[Pure]
public static CellIndex FromSpritePos(Vector2 spritePos)
{
Vector2 vector2 = spritePos / Vector2s.Eight;

return new((int)vector2.X, (int)vector2.Y);
}

It would be nice to just treat a Vector2 as an x, y of type int instead of float. If we owned Vector2, we could create our own Deconstruct. But we don't. With C# 9, we have the ability For ages (where have you been?!) we've had the ability to create an extension method that does this Deconstruct, e.g.:

public static class Extensions
{
public static void Deconstruct(this Vector2 v, out int x, out int y) =>
(x,y) = ((int)v.X, (int)v.Y);
}

... which means we can have a slightly nicer conversion between the two:

[Pure]
public static CellIndex FromSpritePos(Vector2 spritePos)
{
Vector2 vector2 = spritePos / Vector2s.Eight;

(int x, int y) = vector2;

return new(x,y);
}

One minor improvement; we can Inline Variable vector2:

[Pure]
public static CellIndex FromSpritePos(Vector2 spritePos)
{
(int x, int y) = spritePos / Vector2s.Eight;

return new(x,y);
}

... and if you've not seen the [Pure] attribute before, it's very a very useful attribute that's part of JetBrains.Annotations #. Pure means:

  /// <summary>
/// Indicates that a method does not make any observable state changes.
/// The same as <c>System.Diagnostics.Contracts.PureAttribute</c>.
/// </summary>
/// <example><code>
/// [Pure] int Multiply(int x, int y) =&gt; x * y;
///
/// void M() {
/// Multiply(123, 42); // Warning: Return value of pure method is not used
/// }
/// </code></example>
[AttributeUsage(AttributeTargets.Method)]
[Conditional("JETBRAINS_ANNOTATIONS")]
public sealed class PureAttribute : Attribute
{
}

ReSharper improvements #

As an aside, I upgraded ReSharper to the latest EAP (2020.3.EAP10) and discovered a new inspection which spotted that some of the structs could be readonly structs:

I wrote a while back about readonly, specifically readonly parameters (#). readonly structs means that no copies have to be made if it is used as a method argument.

So I made it readonly struct and proceeded to mark all method arguments that use it as in parameters:

    public void PillEaten(in CellIndex cellPosition)

There were other non C# 9 features in CellIndex that I changed too:

... became ...

 return obj is CellIndex index && Equals(index);

🙏🙏🙏

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