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.
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
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
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)?
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
}
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()
};
}
}
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;
}
}
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 Life
instance
From scratch - meaning I just tell it how many rows and columns of
Cell
s I want and theLife
just initializes its 2D_cells
array withCell
s in theDead
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 itsCell
'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();
}
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.
- Any live cell with two or three live neighbours survives.
- Any dead cell with three live neighbours becomes a live cell.
- 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;
}
Let me quickly walk you through the code. I'm traversing the 2D array of Cell
s, 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
- Blazor.Extensions.Canvas - https://github.com/BlazorExtensions/Canvas
- Excubo.Blazor.Canvas https://github.com/excubo-ag/Blazor.Canvas
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
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 @using
s
@inject IJSRuntime _jsRuntime;
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;
}
}
(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>
}
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 Cells
s.
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>
The function takes:
-
canvas
- a reference to an HTML<canvas>
-
mask
- a 2D array where each element stands for the state of theCell
at those coordinates. 0 means theCell
is dead and 1 means theCell
is alive. -
maskColors
- a 1D array containing the color to use for eachCell
state. So it is a mapping from the values frommask
to usable colors. -
_cellEdgeAndSpacingLength
- the length of aCell
's edge, including the space to the nextCell
. -
_cellEdgeLength
- the length of aCell
'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);
}
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" />
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>
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>
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 Cell
s 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))"
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);
}
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>
And in the @code
block, add this method
private void OnTickClicked()
{
_life?.Tick();
}
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 Cell
s 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>
In the @code
block add this method
private void OnClear()
{
InitData();
}
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;
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>
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);
}
The method serializes the Life
s 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>
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
- Pick a file with the game state that you want to restore.
- Deserialize its content to a 2D array of
Cell
s. - Create a new
Life
instance with that 2D array ofCells
.
Add this element just bellow the last button
<InputFile OnChange="@OnSelectedFileChanged" class="btn"></InputFile>
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);
}
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 Cell
s 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();
}
This new method makes use of the second constructor of Life
which takes the Cell
s 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>
And then publish a Release build of your app, which looks like this from the CLI
dotnet publish -c Release
If it complains about a missing workload, you can install it with
sudo dotnet workload install wasm-tools
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
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
Then navigate to the publish folder and run
dotnet-serve
Thanks for reading!
Top comments (1)
Nice write-up.
I also made a Game Of Life in Blazor - without canvas - you can see it here blazorrepl.com/repl/mFuGFkOG17Fc9M...