An introduction to using the BlazorServer web hosting framework and Fluxor middleware to produce interactive web-based applications. An example application of the 2048 tile sliding game is used to illustrate the techniques and patterns employed.
Introduction
This article details a Blazor Server implementation of the 2048 tile sliding game that employs the Fluxor.Blazor.Web Nuget package of Message Oriented Middleware. The emphasis here will be on integrating several Blazor Components into the Fluxor pattern. The game engine, that progresses the game, was described in a previous article.
The Blazor Server Web Hosting Pattern
If you are new to Blazor, it may be useful to know that the Blazor Server Hosting pattern builds web pages by the use of components. Components are, typically, entities containing HTML mark up and code that handle specific aspects of the user interface that a razor page hosts. Any processing that the application requires is done on the server and the components respond to that activity by updating the user interface (UI). Communication between the server and components is handled by the framework so there's usually no requirement to write bespoke javascript to enable it.
Implementing the Fluxor Pattern
The state of variables relating to a Blazor component does not normally persist after the component goes out of scope so when the component comes back into scope they revert to their default values. The Fluxor pattern persists the state of components throughout the application by providing and managing State
classes. Fluxor also enables a component to act solely as a user interface with no knowledge of both a game engine and any external components. A component typically references a State
class that holds the UI variables, but the component doesn't directly update the State
class. Any actions instigated by a component are dispatched in a message to an Effects
class. The Effects
class processes any tasks that the action requires. Changes to the state of the component arising from the tasks are then dispatched in a message to a Reducers
class. A reducer method takes the pending changes from the message and the existing State
and combines them into a new State
instance. The new State
instance triggers an update response in the component and the component 're-renders’. This may seem like a protracted and pedantic implementation but it gives an excellent degree of separation between Fluxor defined entities. Component communication with the outside world is limited to receiving and sending messages. but they don't know anything about the sender of messages they receive or the recipient of messages that they dispatch
Some Design Considerations.
Fluxor uses distinct execution pathways known as 'use cases' to progress the application and facilitate debugging. Important design considerations are to determine how many use cases are required and the optimal way to apply the Fluxor pattern of event, effect, state change to each one. The example application is designed to have two use cases.
- A
DashboardUseCase
. This use case manages a DashboardComponent
and the processes associated with it to enable the display of variables related to the state of play and to handle opening and closing the game GameboardUseCase
. This is concerned with the implementation of the game engine. The main component here is the GameboardComponen
t that's used to house the 4 rows of 4 child TileComponents
that the game requires
There is another component the NavigationComponent
that controls the simulation of sliding the tiles across the horizontal and vertical planes of the game board. It is a member of the GameboardUseCase
namespace but it also changes the state of the DashboardUseCase
. The suggested file structure to hold the Fluxor related entities is to use a parent directory named Store with child directories named after the use cases. These child directories hold an Effects
class a Reducers
class and a State
class. The message classes are placed in the parent directory
The Razor Page
The Components are hosted on a single Razor page. The app uses Blazor's integrated Bootstrap to layout the main components.
<h1>2048</h1>
<DashboardComponent />
<div class="container">
<div class="d-sm-flex flex-row justify-content-evenly">
<GameBoardComponent />
<NavigationComponent />
</div>
</div>
The Code initialises the DashboardComponent
and GameboardComponent
only on the first render as the DashboardResetAction
results in the state's IsInitialised
variable being set to true. The Dispatcher
'posts' the messages
[Inject]
public IDispatcher? Dispatcher { get; set; }
[Inject]
private IState<DashboardState>? DashboardState { get; set; }
protected override void OnInitialized()
{
if(DashboardState!.Value.IsInitialised is false)
{
Dispatcher!.Dispatch(new DashboardResetAction());
Dispatcher.Dispatch(new GameBoardResetAction());
}
base.OnInitialized();
}
The Messages that the Dispatcher
uses are record
types. Records are immutable so a new message needs to be constructed every time it is used. The record
syntax makes them easy to define.
public record DashboardResetAction();
public record UpdateBoardAction(int NewTileId, bool IsRunning);
The Gameboard Component.
@using BlazorServer2048.Store
@using BlazorServer2048.Components
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
<div class="container">
@for(int r=0;r<4;r++)
{
<div class="d-sm-flex flex-row">
@for(int c=0;c<4;c++)
{
<TileComponent Row="@r" Col="@c"/>
}
</div>
}
</div>
It's important that the Component inherits from FluxorComponent
as that is the Fluxor Framework's user interface base class. The Component uses nested for
loops to render 4 rows of 4 columns of TileComponents
. All the functionality is contained within the child TileComponent
The TileComponent.
Scalable Vector Graphics (Svg) are used to display a rectangle and a text type that's centred in the middle of the rectangle. The fill colour and the text are rendered dynamically.
<div>
<svg width="100" height="100">
<rect x="0" y="0" width="100" height="100"
fill="@colourList[GameboardState!.Value.BoardTiles[Id].TileValue]"
stroke="white" stroke-width="8" />
<text x="50%" y="50%" dominant-baseline="middle" font-weight="bold"
text-anchor="middle">@GameboardState!.Value.BoardTiles[Id].FaceValueText</text>
</svg>
</div>
The Component's backing class, BoardTile
, has a TileValue property
that's stored as a power of 2 with a range of 0 to 11. It's used to index into a ColourList
to return the fill value. Another property, FaceValueText,
uses its getter to transform the TileValue
into the appropriate string representation of the decimal value of the power. An empty string is returned when the power is 0 as it represents a blank tile without a value. The simulation of tile movement is achieved by transferring a tile's value to and from adjacent tiles
public string FaceValueText
{
get
{
int faceValue = TileValue == 0 ? 0 : (1 << TileValue);
return faceValue == 0 ? string.Empty : faceValue.ToString();
}
}
The NavigationComponent.
The NavigationComponent
is simply 4 Buttons
laid out using Bootstrap's row
and col
classes.
<div class="container" >
<div class="d-flex flex-column justify-content-center vertical-box" >
<div class="row" >
<h4 >Slide Control </h4 >
</div >
<div class="row" >
<div class="col-md-1 offset-md-1" >
<button type="button" class="btn btn-primary"
@onclick="((args) = > OnDirectionUpdated(Direction.Up))" >N </button >
</div >
</div >
<div class="row" >
<div class="col-md-1 " >
<button type="button" class="btn btn-primary"
@onclick="((args) = > OnDirectionUpdated(Direction.Left))" >W </button >
</div >
<div class="col-md-1 offset-md-1" >
<button type="button" class="btn btn-primary"
@onclick="((args) = > OnDirectionUpdated(Direction.Right))" >E </button >
</div >
</div >
<div class="row" >
<div class="col-md-1 offset-md-1" >
<button type="button" class="btn btn-primary"
@onclick="((args) = > OnDirectionUpdated(Direction.Down))" >S </button >
</div >
</div >
</div >
</div >
The OnDirectionUpdated
method dispatches a DirectionSelectedAction
with the appropriate member of the Direction enum
as a property value. The handler for the message is inside GameboardUseCase Events
class
[EffectMethod]
public Task HandleDirectionSelectedAction(DirectionSelectedAction action, IDispatcher dispatcher)
{
int score;
if (GameService.IsRunning)
{
(bool isRunning, score, int newTileId) = GameService.PlayMove(action.Direction);
dispatcher.Dispatch(new DirectionSelectedActionResult(isRunning, score + State.Value.Total));
if (newTileId == -1) return Task.CompletedTask;
dispatcher.Dispatch(new UpdateBoardAction(newTileId,isRunning));
}
return Task.CompletedTask;
}
The DashboardComponent.
The DashboardComponent
updates when the DashboardState
changes. But it also responds to the receipt of a GameOverAction
message by displaying a dialog box (Modal) when the game ends. It subscribes to receiving the GameOverAction
message by calling the FluxorComponent's SubscribeToAction
method and uses the Nuget package Blazored.Modal to deploy the dialog box.
CascadingParameter]
public IModalService? Modal { get; set; }
protected override void OnInitialized()
{
base.OnInitialized();
SubscribeToAction<GameOverAction>(async (action) => await OnGameOver(action.Message));
}
private async Task OnGameOver(string msg)
{
var options = new ModalOptions { Position = ModalPosition.TopRight };
var modalComponent = Modal?.Show<ModalComponent>(msg, options);
if (modalComponent == null) return;
ModalResult result = await modalComponent.Result;
if (result.Confirmed)
{
StopStartSelector();
}
}
private void StopStartSelector()
{
Dispatcher!.Dispatch(new StopStartAction());
}
The State Classes
The DashboardState
holds several value types. State classes need to have a default constructor.
[FeatureState]
public record DashboardState(int Total,
bool IsRunning,
bool IsGameWon,
bool IsInitialised)
{
public DashboardState() : this(0, false, false,false)
{
}
}
The GameboardState
needs to store 16 instances of the GameboardComponent's
backing class BoardTile
. The documentation advises employing an ImmutableArray<T>
for that.
[FeatureState]
public record GameboardState
{
public GameboardState()
{
var boardTiles = new List<BoardTile>(16);
for (int i = 0; i < 16; i++)
{
boardTiles.Add(new BoardTile(i, 0));
}
BoardTiles = ImmutableArray.Create(boardTiles.ToArray());
}
public ImmutableArray<BoardTile> BoardTiles { get; init; }
}
Reducers Class
The DashboardComponent
Reducers
Class makes use of the with
keyword to update the state.
[ReducerMethod]
public static DashboardState HandleDirectionSelectedActionResult(
DashboardState state,
DirectionSelectedActionResult action)
{
return state with { IsRunning = action.IsRunning, Total=action.Total};
}
The GameboardComponent
Reducers
class is a bit more complex as it needs to update an immutable array.
[ReducerMethod]
public static GameboardState ReduceUpdateAllTilesActionResult(
GameboardState state,
UpdateAllTilesActionResult action)
{
var updatedTiles = state!.BoardTiles.ToArray();
foreach (var boardTile in state.BoardTiles)
{
if (action.ChangedTileValues!.TryGetValue(boardTile.Id, out int value))
{
updatedTiles[boardTile.Id] = new BoardTile(boardTile.Id, value);
}
}
var newArray = ImmutableArray.Create(updatedTiles);
return state with { BoardTiles = newArray };
}
There is a method that updates a single item an immutable array. It's used when a new tile is inserted after a move is completed
var updatedTiles= state!.BoardTiles.SetItem(
action.TileId, new BoardTile(action.TileId, action.TileValue));
return state with { BoardTiles = updatedTiles };
App.razor.
Blazor employs a structured render tree of components to update a page. App.razor
is the root component of the tree; by default it has a single child component named Router
. Two further components need to be added as shown below.
<Fluxor.Blazor.Web.StoreInitializer/>
<CascadingBlazoredModal>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingBlazoredModal>
The CascadingBlazoredModal
component is required to enable the modal pop up. The reference to the Fluxor.Blazor.Web.StoreInitializer
is needed to setup the Fluxor framework. The framework relies extensively on the appropriate attributes being in place within the Effects, Reducers
and State
classes. It's the attributes that determine the use of a class rather than the class names but sticking with the names used in the documentation is probably the best option. Other services that the application requires are not included here, they are added within Program.cs
Program.cs.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddFluxor(options => options.ScanAssemblies(typeof(Program).Assembly));
builder.Services.AddSingleton<WeatherForecastService>();
builder.Services.AddSingleton<IGameService, GameService>();
builder.Services.AddBlazoredModal();
var app = builder.Build();
Conclusion.
The Fluxor pattern of event, effect, state change when applied to predefined execution pathways can produce robust applications that are easy to maintain, debug and expand.