DEV Community

Cover image for Build the Game of Life with Blazor
Marius M.
Marius M.

Posted on • Updated on

Build the Game of Life with Blazor

Welcome to my series of posts where I'm building the famous Game of Life with different UI technologies.
In this post I'll show you how it is done in Blazor Webassembly.

Let me know in the comments if you're interested in seeing implementations in Xamarin.Forms/MAUI, WPF or Flutter.


Game of Life in motion

Here's the code: https://github.com/mariusmuntean/GameOfLife


Prerequisites

  • .NET - I bet you knew this one. Out of curiosity I went with .NET 6 Preview 7.
  • Visual Studio Code or Visual Studio ('proper' or 'for Mac') or Rider - I really wanted to use VSCode for this but OmniSharp would not play ball so I resorted to Rider.

Create the Blazor Webassembly project

If you're using Visual Studio proper or Visual Studio for Mac or even Rider, creating the project is just a series of clicks to create a new solution, then choose Blazor Webassembly App as the project type and give it a name.

I went the CLI way and used this command to create the project

dotnet new blazorwasm -o gol.blazorwasm
Enter fullscreen mode Exit fullscreen mode

Start the project, just to make sure that everything works as expected.
You now have the option to choose between a normal run with or without the debugger and the hot-reload feature.
I went with hot-reloading. Just open a terminal/CLI window (my IDE has an integrated one) and run this command

dotnet watch
Enter fullscreen mode Exit fullscreen mode

You should now be able to update files in your IDE and upon saving, they should be picked up automagically and the browser window will refresh.
At some point you'll be asked this

Do you want to restart your app - Yes (y) / No (n) / Always (a) / Never (v)?
Enter fullscreen mode Exit fullscreen mode

I chose Always.

Business Logic

In your project create a new directory and call it Models. Inside Models create an enum called CellState

public enum CellState
{
    Dead,
    Alive
}
Enter fullscreen mode Exit fullscreen mode

The game consists of a 2D grid where each slot is taken up by a cell. A cell can be either dead or alive. Now add the Cell class, in another file

using System;
using System.Text.Json.Serialization;

namespace gol.blazorwasm.Models
{
    public class Cell
    {
        /// <summary>
        /// For the deserializer.
        /// </summary>
        /// <param name="currentState"></param>
        /// <param name="nextState"></param>
        [JsonConstructorAttribute]
        public Cell(CellState currentState, CellState nextState)
        {
            CurrentState = currentState;
            NextState = nextState;
        }

        public Cell(CellState currentCellState = CellState.Dead)
        {
        }

        public CellState CurrentState { get; private set; } = CellState.Dead;
        public CellState NextState { get; set; } = CellState.Dead;

        public void Tick()
        {
            CurrentState = NextState;
            NextState = CellState.Dead;
        }

        public void Toggle() => CurrentState = CurrentState switch
        {
            CellState.Alive => CellState.Dead,
            CellState.Dead => CellState.Alive,
            _ => throw new ArgumentOutOfRangeException()
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

The CurrentState of a Cell tells us how the cell is currently doing. Later we'll have to compute the new state of each Cell based on the state of its neighbors. To make the code simpler, I decided to store the next state of the Cell in the NextState property.
When the game is ready to transition each Cell into its next state, it can call Tick() on the Cell instance and the NextState becomes the CurrentState.
The method Toggle() will allow us to click somewhere on the 2D grid and kill or revive a Cell.

Let's talk about life. At the risk of sounding too reductionist, it's just a bunch of interacting cells. So we'll create one too

using System;
using System.Collections.Generic;
using System.Linq;

namespace gol.blazorwasm.Models
{
    using CellsChanged = Action<Cell[][]>;

    public class Life
    {
        private readonly Cell[][] _cells;
        private readonly int _rows;
        private readonly int _cols;

        public Life(int rows, int cols)
        {
            if (rows <= 0 || cols <= 0)
            {
                throw new ArgumentOutOfRangeException(nameof(rows) + " " + nameof(cols), "the rows and columns cannot be 0 or less");
            }

            _rows = rows;
            _cols = cols;
            _cells = new Cell[rows][];
            for (var row = 0; row < rows; row++)
            {
                _cells[row] ??= new Cell[cols];
                for (var col = 0; col < cols; col++)
                {
                    _cells[row][col] = new Cell(CellState.Dead);
                }
            }
        }

        public Life(Cell[][] initialCells, CellsChanged onNewGeneration = null)
        {
            var newRows = initialCells.GetLength(0);
            var newCols = initialCells.GetLength(0);

            if (newRows <= 0 || newCols <= 0)
            {
                throw new ArgumentOutOfRangeException("one or both dimensions of the provided 2d array is 0");
            }

            _cells = initialCells;
            _rows = newRows;
            _cols = newCols;
        }

        public Cell[][] Cells => _cells;
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's break down what we just created. Life is a class that keeps track of a bunch of cells. For that we're using Cell[][] _cells which is just a 2D array of our simple Cell class. Having a 2D array allows us to know exactly where each cell is and who its neighbors are.
Traversing the 2D array can be cumbersome so I keep track of its dimensions with the fields _rows and _columns.

There are two ways in which I want to be able to create a new Lifeinstance

  • From scratch - meaning I just tell it how many rows and columns of Cells I want and the Life just initializes its 2D _cells array with Cells in the Dead state.
    Basically, that's what the first constructor does.

  • From a file - think of a saved game state. We'll later save the state of the game into a file and then load it up. When loading the saved game state, we need to tell the Life instance what each of its Cell's state should be.

At this point we can create a new Life, where all the cells are either dead (first constructor) or in a state that we received from 'outside' (second constructor).

Our Life needs a bit more functionality and then it is complete. The very first time we load up the game, all the cells will be dead. So it would be nice to be able to just breathe some life into the dead cells.
For that, Life needs a method that takes the location of a Cell and toggles its state to the opposite value.

    public void Toggle(int row, int col)
    {
        if (row < 0 || row >= _rows)
        {
            throw new ArgumentOutOfRangeException(nameof(row), row, "Row value invalid");
        }

        if (col < 0 || col >= _cols)
        {
            throw new ArgumentOutOfRangeException(nameof(col), col, "Column value invalid");
        }

        _cells[row][col].Toggle();
    }
Enter fullscreen mode Exit fullscreen mode

The Life instance just makes sure that the specified location of the Cell makes sense and then tells that Cell to toggle its state. If you remember, the Cell class can toggle its state, if told to do so.

The last and most interesting method of Life implements the 3 rules of the Game of Life.

  1. Any live cell with two or three live neighbours survives.
  2. Any dead cell with three live neighbours becomes a live cell.
  3. All other live cells die in the next generation. Similarly, all other dead cells stay dead.
public void Tick()
{
    // Compute the next state for each cell
    for (var row = 0; row < _rows; row++)
    {
        for (var col = 0; col < _cols; col++)
        {
            var currentCell = _cells[row][col];
            var cellNeighbors = GetCellNeighbors(row, col);
            var liveCellNeighbors = cellNeighbors.Count(cell => cell.CurrentState == CellState.Alive);

            // Rule source - https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life#Rules
            if (currentCell.CurrentState == CellState.Alive && (liveCellNeighbors == 2 || liveCellNeighbors == 3))
            {
                currentCell.NextState = CellState.Alive;
            }
            else if (currentCell.CurrentState == CellState.Dead && liveCellNeighbors == 3)
            {
                currentCell.NextState = CellState.Alive;
            }
            else
            {
                currentCell.NextState = CellState.Dead;
            }
        }
    }

    // Switch to the next state for each cell
    for (var row = 0; row < _rows; row++)
    {
        for (var col = 0; col < _cols; col++)
        {
            var currentCell = _cells[row][col];
            currentCell.Tick();
        }
    }
}

private List<Cell> GetCellNeighbors(int row, int col)
{
    var neighbors = new List<Cell>(8);

    for (var rowOffset = -1; rowOffset <= 1; rowOffset++)
    {
        for (var colOffset = -1; colOffset <= 1; colOffset++)
        {
            if (rowOffset == 0 && colOffset == 0)
            {
                // Skip self
                continue;
            }

            var neighborRow = row + rowOffset;
            var neighborCol = col + colOffset;
            if (neighborRow < 0 || neighborRow >= _rows)
            {
                continue;
            }

            if (neighborCol < 0 || neighborCol >= _cols)
            {
                continue;
            }

            neighbors.Add(_cells[neighborRow][neighborCol]);
        }
    }

    return neighbors;
}
Enter fullscreen mode Exit fullscreen mode

Let me quickly walk you through the code. I'm traversing the 2D array of Cells, making use of the rows and columns. For each cell I'm looking at its neighbors and based on the 3 game rules I'm computing the next state of the Cell.
When I'm done with that, I'm traversing the 2D grid again (I know, not very efficient of me, but I went for readable code) and telling each Cell to switch to its next state.

We're done with the business logic. It's time for the UI.

UI

Similarly to React (see the previous post in this series), Blazor also uses a hierarchy of components. The similarities don't end here, in Blazor you can also mix code with markup and even go code-only, though I wouldn't recommend this.

When you're mixing code and markup you're making use of the Razor syntax. There's usually a @code{}' block that holds UI logic and some markup which can also be sprinkled with @directives that allow you to write inline code that emits HTML.

As far as I know the location of the @code{} block is not important and I prefer having it immediately after the @using and @inject statements. The markup lives just below the @code{} directive. That's also similar to React.

Canvas drawing in Blazor

My first attempt was to use one of the Blazor libraries for drawing onto the canvas. These two looked the most promising

They're both easy to set up and you'll have some pixels on your canvas in no time. The issue is that I needed to draw hundreds of rectangles every time the data in the game changes. To produce each rectangle, a call from the C# code to Javascript is necessary, which still has a big overhead.
I tried batching too, but no dice.

In the end I gave up on these libraries and conceded that in Blazor development you still have to use Javascript, despite Microsoft's promise "Use only C# in the browser, no more JavaScript!".

SimpleLife.razor

This component will use a Life instance to render the game. The Life instance will be created by the component or be loaded from a file.

Let's start by adding a Components directory. Inside this directory add a new razor Component and call it SimpleLife.razor. If you're adding it as a simple text file and then renaming its, just make sure that the Build Action is set to "Content".

Add these @using statements to the top of the file

@using gol.blazorwasm.Models
@using Microsoft.AspNetCore.Components.Web
Enter fullscreen mode Exit fullscreen mode

The first one just helps us in using our modes. The second one made Rider (my IDE) happy.

Now let the framework inject the Javascript runtime interop service. Add this @inject directive below your @usings

@inject IJSRuntime _jsRuntime;
Enter fullscreen mode Exit fullscreen mode

We'll use this interop service in a bit.

Get ready, we're adding the @code block. Below @inject add this this

@code
{
    private const int SpaceForButtons = 30;

    ElementReference _canvasRef;

    private Life? _life;
    private int _cellEdgeAndSpacingLength;
    private double _cellEdgeLength;
    private int _canvasWidth;
    private int _canvasHeight;

    [Parameter]
    public int Columns { get; set; }

    [Parameter]
    public int Rows { get; set; }

    [Parameter]
    public int PixelWidth { get; set; }

    [Parameter]
    public int PixelHeight { get; set; }

    protected override void OnParametersSet()
    {
        InitData();
    }

    private void InitData()
    {
        _life = new Life(Rows, Columns);

        // Glider
        _life.Toggle(2, 2);
        _life.Toggle(3, 2);
        _life.Toggle(4, 2);
        _life.Toggle(4, 1);
        _life.Toggle(3, 0);

        UpdateCellAndCanvasSize();
    }

    private void UpdateCellAndCanvasSize()
    {
        _cellEdgeAndSpacingLength = Math.Min(PixelWidth / Columns, (PixelHeight - SpaceForButtons) / Rows);
        _cellEdgeLength = 0.9 * _cellEdgeAndSpacingLength;

        _canvasWidth = _cellEdgeAndSpacingLength * Columns;
        _canvasHeight = _cellEdgeAndSpacingLength * Rows;
    }
}
Enter fullscreen mode Exit fullscreen mode

(Read in young Keanu Reeves' voice) Whoa what's going on here?
We're declaring a constant for the vertical space to reserve for some buttons.
Then we're declaring a variable _life for the Life instance that's going to be rendered.
The next two variables hold the values for a rendered Cell's edge length, with and without the space between cells.
The last variables will hold the exact canvas dimensions, in pixels.

Next up are the properties. If you pay close attention you'll notice the [Parameter] attributes. This means that they're going to be passed in from outside, when another component uses this one.
The Rows and Columns properties let us know how many rows and columns our game should have.
The PixelWidth and PixelHeight properties tell our component what the available space is. We could've made the component responsive to layout changes, but I went for simple code. I'm open to simple solutions, so please let me know in the comments how to make it responsive.

As its name implies, OnParametersSet() will be called when the parent component provided values for our parameter properties. I'm using this component lifecycle method to initialize a new Life and compute the values for the variables in InitData().
_life is assigned to a new instance of Life that directly uses the Rows and Columns that were passed to our component. Immediately after that I'm making use of the Life's Toggle() method to toggle a few cells to the Alive state. They form a so-called 'Glider', one of the canonical shapes in the Game of Life.
Then I'm doing a bit of computation to figure out the necessary Cell edge length so as to fit the requested columns and rows in the available space.

Before continuing with the @code block, let's add some markup. Add this code just below your @code block

@if (_life != null)
{
    <div>
        <canvas width="@_canvasWidth"
                height="@_canvasHeight"
                @ref="@_canvasRef">
        </canvas>
    </div>
}
else
{
    <div>get a Life</div>
}
Enter fullscreen mode Exit fullscreen mode

Yay, some Razor syntax! 🎉
So, if our code that initializes the Life instance ran, then _life is set and the other values were computed. In that case render the canvas with the computed dimensions and store a reference to that canvas in _canvasRef. Otherwise just emit a <div>with a sad message.

Before continuing, let's recap: we react to the component parameters being set by initializing an instance of Life with the requested number of rows and columns, then we're computing the size of the canvas that's going to contain the game's cells and the optimal size Cell edge length to fit them all on the canvas.

Phew, that was a lot! We're almost ready to see the little Cellss.

Crossing boundaries

As I mentioned before we won't be using any drawing library for Blazor. Instead we're going to use the elegant canvas api.
That's not directly possible from WebAssembly, so we're making use of the Javascript interop here.
We're going to write a Javascript function that can use the canvas API to draw our cells and we're going to attach it to the window object.
In your wwwroot/index.html add the following script tag to the body of the document

<script>
    window.renderCellsOnCanvas = (canvas, mask, maskColors, _cellEdgeAndSpacingLength, _cellEdgeLength) => {
        mask.forEach((row, rowIndex) => {
            row.forEach((cellState, colIndex) => {
                var ctx = canvas.getContext("2d");
                ctx.beginPath();
                ctx.fillStyle = maskColors[cellState];
                ctx.rect(colIndex * _cellEdgeAndSpacingLength, rowIndex * _cellEdgeAndSpacingLength, _cellEdgeLength, _cellEdgeLength);
                ctx.fill();
            })
        })
    }
</script>
Enter fullscreen mode Exit fullscreen mode

The function takes:

  • canvas - a reference to an HTML <canvas>
  • mask - a 2D array where each element stands for the state of the Cell at those coordinates. 0 means the Cell is dead and 1 means the Cell is alive.
  • maskColors - a 1D array containing the color to use for each Cell state. So it is a mapping from the values from mask to usable colors.
  • _cellEdgeAndSpacingLength - the length of a Cell's edge, including the space to the next Cell.
  • _cellEdgeLength- the length of a Cell's edge.

It then loops over the 2D array and maps every value to a color and then draws a square of that color, at the current Cell position.

Back to the SimpleLife

With this Javascript function available on the window object, we can go back to the SimpleLife component and add another lifecycle method that computes the necessary parameters for the Javascript function and then calls it. Below OnParametersSet() add this override

   protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        var mask = new byte[Rows][];
        for (var row = 0; row < Rows; row++)
        {
            mask[row] = new byte[Columns];
            for (var col = 0; col < Columns; col++)
            {
                var currentCell = _life.Cells[row][col];
                mask[row][col] = (byte)currentCell.CurrentState;
            }
        }
        var maskColors = new[] { "black", "red" };

        await _jsRuntime.InvokeVoidAsync("renderCellsOnCanvas", _canvasRef, mask, maskColors, _cellEdgeAndSpacingLength, _cellEdgeLength);
    }
Enter fullscreen mode Exit fullscreen mode

It builds the mask parameter by adding a 0 or a 1 for each Cell in our _life. This is also the place where we decide the colors for each CellState.

All that's left is to call the Javascript function with our injected Javascript interop service.

Render the SimpleLife

To see the SimpleLife component in action we need to use it on a page.
In your Pages directory, add a new one called GOL.razor and give it this content

@page "/"
@using gol.blazorwasm.Components
@inject IJSRuntime _jsRuntime

@code
{
    public int PixelWidth { get; set; }
    public int PixelHeight { get; set; }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            // This only works because I added an appropriate Javascript function to the "window" object. See index.html.
            var dimensions = await _jsRuntime.InvokeAsync<WindowDimensions>("getWindowDimensions");
            PixelWidth = Math.Min(dimensions.Width, dimensions.Height);
            PixelHeight = Math.Min(dimensions.Width, dimensions.Height);
            StateHasChanged();
        }
    }

    public class WindowDimensions
    {
        public int Width { get; set; }
        public int Height { get; set; }
    }
}

<SimpleLife Rows="50"
            Columns="50"
            PixelWidth="@PixelWidth"
            PixelHeight="@PixelHeight" />

Enter fullscreen mode Exit fullscreen mode

This page also makes use of a Javascript function that returns the window dimensions. Now that you know how to do it, just add this new <script> tag to your index.html's body

<script>window.getWindowDimensions = function () {
    return {
        width: window.innerWidth,
        height: window.innerHeight,
    };
};</script>
Enter fullscreen mode Exit fullscreen mode

Replace the content of your App.razor with this

<Router AppAssembly="@typeof(App).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData"/>
    </Found>
    <NotFound>
        <p role="alert">Sorry, there's nothing at this address.</p>
    </NotFound>
</Router>
Enter fullscreen mode Exit fullscreen mode

If you still have your dotnet watch command running you should now see a new instance of the Game of Life with a simple Glider.

Back to the SimpleLife, part 2

That was a lot! I know, but you should get away with copy pasting code. 😉

We're ready to make the SimpleLife component interactive. First is toggling Cells from dead to alive and back.

Dead or Alive

In the markup section, locate the <canvas> element and add this attribute

@onclick="@(e => OnCanvasClick(e.OffsetX, e.OffsetY))"
Enter fullscreen mode Exit fullscreen mode

And now implement the method that we're referencing. Just add this method to the @code block

    private void OnCanvasClick(double pixelX, double pixelY)
    {
        if (pixelX < 0 || pixelX > _canvasWidth)
        {
            return;
        }
        if (pixelY < 0 || pixelY > _canvasHeight)
        {
            return;
        }

        // Translate pixel coordinates to rows and columns
        var clickedRow = (int)((pixelY / _canvasHeight) * Rows);
        var clickedCol = (int)((pixelX / _canvasWidth) * Columns);

        _life?.Toggle(clickedRow, clickedCol);
    }
Enter fullscreen mode Exit fullscreen mode

The coordinates of a click event on the canvas as passed to the OnCanvasClick method, which converts pixel coordinates to Cell coordinates, i.e. row and column. Then the appropriate Cell is told to toggle its state.
You should now be able to click anywhere on the canvas to make ⬛️ cells 🟥 and vice versa.

That's nice and dandy, but we want to actually play the Game.
of Life.

They're alive!

For that we want to apply the 3 rules of the game to the current game state and progress to the next game state.

Below the <div>that wraps the <canvas> add a new button like so

<button @onclick=@(e => OnTickClicked()) class="btn btn-primary">Tick</button>
Enter fullscreen mode Exit fullscreen mode

And in the @code block, add this method

    private void OnTickClicked()
    {
        _life?.Tick();
    }
Enter fullscreen mode Exit fullscreen mode

Whenever you click the new button, the Life's Tick() method is invoked. That method applies the game rules and moves every Cell from its current state to its next state.
Through Blazor magic, a re-render is triggered which calls our overriden OnAfterRenderAsync() method which paints the current state onto the canvas.

By the way, if you wonder where the CSS classes come from you should know that Bootstrap is baked into Blazor.

This Tick business was easy! Now it pays off that we've encapsulated that logic in Life.

Let's continue and keep the critters under control.

Restoring order

Let's assume that the game got out of hand and the Cells are plotting to overthrow the current world order. We need a way to clear the game board.
Start by adding a new button, below the previous one

<button @onclick=@(e => OnClear()) class="btn btn-primary">Clear</button>
Enter fullscreen mode Exit fullscreen mode

In the @code block add this method

    private void OnClear()
    {
        InitData();
    }
Enter fullscreen mode Exit fullscreen mode

There's not much to it, the method just calls our old InitData() method which overwrite out Life instance with a fresh one.

Saving game state

Come on, admit it, you've grown fond of some of the critters and you want to play with them later.
No problemo! we'll save the game state to a file.
Below the existing @using statements, add this one

@using System.Text.Json;
Enter fullscreen mode Exit fullscreen mode

this gives us access to the new Json serializer from Asp.Net.

Next step is to add yet another button like so

<button @onclick=@OnDownload class="btn btn-primary">Save</button>
Enter fullscreen mode Exit fullscreen mode

The referenced method should look like this

    private async Task OnDownload()
    {
        var cellsJsonStr = JsonSerializer.Serialize(_life?.Cells);
        var fileName = $"game state {DateTime.Now.ToShortDateString()} {DateTime.Now.ToShortTimeString()}.json";
        await _jsRuntime.InvokeAsync<bool>("downloadStringAsFile", cellsJsonStr, fileName);
    }
Enter fullscreen mode Exit fullscreen mode

The method serializes the Lifes cells as a JSON string, generates a name for the file that will hold the saved game state and ... huh, Javascript interop again.
You already know the drill. Add this new Javascript function

<script>window.downloadStringAsFile = function (content, fileName) {
    const anchor = document.createElement("a");
    anchor.href = "data:text/plain;charset=utf-8," + encodeURIComponent(content);
    anchor.download = fileName;
    document.body.appendChild(anchor);
    try {
        anchor.click();
    } catch (e) {
        console.log(e);
        return false;
    } finally {
        document.body.removeChild(anchor);
    }
    return true;
};</script>
Enter fullscreen mode Exit fullscreen mode

The Javascript function uses a pretty standard way of downloading dynamically generated content.
And bam! Your browser should inform you that a new file will be downloaded. Just save if somewhere you remember because we'll need it shortly.

Loading saved game state

Loading saved game state will follow these high-level operations

  1. Pick a file with the game state that you want to restore.
  2. Deserialize its content to a 2D array of Cells.
  3. Create a new Life instance with that 2D array of Cells.

Add this element just bellow the last button

<InputFile OnChange="@OnSelectedFileChanged" class="btn"></InputFile>
Enter fullscreen mode Exit fullscreen mode

Now add the referenced method

    private async Task OnSelectedFileChanged(InputFileChangeEventArgs eventArgs)
    {
        if (!eventArgs.File.Name.EndsWith(".json"))
        {
            Console.WriteLine("Stick to JSON files");
            return;
        }

        using var fileStream = eventArgs.File.OpenReadStream();
        var deserializedCells = await JsonSerializer.DeserializeAsync<Cell[][]>(fileStream);
        if (deserializedCells == null)
        {
            Console.WriteLine("Couldn't deserialize the cells. So sad.");
        }

        if (deserializedCells.Length != Rows || deserializedCells[0].Length != Columns)
        {
            Console.WriteLine($"Expected to load cells with {Rows} rows and {Columns} columns");
            return;
        }

        InitData(deserializedCells);
    }
Enter fullscreen mode Exit fullscreen mode

The method is invoked when you click the InputFile and pick a file. It makes sure you chose a JSON file and then opens a .NET Stream to the file content.
Using the JSON Serializer from before, it tries to deserialize the file content to a Cell[][] and if that works and the resulting 2D array of Cells has the correct shape it is passed to yet another method that should look like this

    private void InitData(Cell[][] cells)
    {
        _life = new Life(cells);

        UpdateCellAndCanvasSize();
    }
Enter fullscreen mode Exit fullscreen mode

This new method makes use of the second constructor of Life which takes the Cells from 'outside' and just works with them.

Conclusion

For this second post in the series I had even more fun than before, but I also was frustrated a lot more than expected.
I was disappointed by the bad interop performance while trying the two Blazor canvas libraries. That's not a fault of the libs, but of the general state of Blazor.
Nonetheless with a little Javascript foo I got around that and the performance of this Blazor implementation is as good as that of the React implementation.

Another way of improving the performance that I found early on was to use Ahead-of-Time compilation.
For that you need to add this to your .csproj file, right inside the first <PropertyGroup> tag

<RunAOTCompilation>true</RunAOTCompilation>
Enter fullscreen mode Exit fullscreen mode

And then publish a Release build of your app, which looks like this from the CLI

dotnet publish -c Release
Enter fullscreen mode Exit fullscreen mode

If it complains about a missing workload, you can install it with

sudo dotnet workload install wasm-tools
Enter fullscreen mode Exit fullscreen mode

After 10 minutes you'll have an AOT-compiled app that should be much faster than the one you saw while developing.

To try out the AOT version locally either use

npx serve
Enter fullscreen mode Exit fullscreen mode

if you have it, or if you're like me and like to stay in the .NET world as much as possible you can use dotnet-serve. Fist install it

dotnet tool install --global dotnet-serve
Enter fullscreen mode Exit fullscreen mode

Then navigate to the publish folder and run

dotnet-serve
Enter fullscreen mode Exit fullscreen mode

Thanks for reading!

Discussion (1)

Collapse
sqlmistermagoo profile image
SQL-MisterMagoo

Nice write-up.

I also made a Game Of Life in Blazor - without canvas - you can see it here blazorrepl.com/repl/mFuGFkOG17Fc9M...