As we plan to refactor and expand the combat system, we notice that there are a lot of messages returned from combat methods that need to be displayed. Our plan is to refactor the combat code out of the GameSession
class and into its own class that manages the intricacies of combat.
To make it easier to move the combat logic into its own class, we need to create a separate DisplayMessageBroker
class that can be used by any object in the game to send messages and is watched by the presentation layer. The DisplayMessageBroker
is an example of the Observer design pattern.
The Observer pattern is a form of the publisher/subscriber model that uses events to sent messages between two loosely coupled objects… in our case it’s the combat class and the displayed messages list. The pattern defines Subject
and Observer
objects. When a subject changes state, all registered observers are notified and updated automatically.

- The sole responsibility of a subject is to maintain a list of observers and to notify them of state changes by using an event. The
DisplayMessageBroker
is our subject. - The responsibility of observers is to register (and unregister) themselves on a subject (to get notified of state changes) and to update their state when they are notified.
- This makes subjects and observers loosely coupled. Subjects and observers have no explicit knowledge of each other. Observers can be added and removed independently at run-time.
- This notification-registration interaction is also known as publish-subscribe.
For now, we’ll only have one subscriber/observer, the GameScreen
. But, this message broker will give us flexibility and make it easier to move our current combat code into another class.
Game Engine Changes
To start these changes, we are going to create the DisplayMessageBroker
class in the SimpleRPG.Game.Engine
project and Services folder.
using SimpleRPG.Game.Engine.Models;
using System;
namespace SimpleRPG.Game.Engine.Services
{
public class DisplayMessageBroker
{
// Use the Singleton design pattern for this class,
// to ensure everything in the game sends messages through this one object.
private static readonly DisplayMessageBroker _messageBroker = new DisplayMessageBroker();
private DisplayMessageBroker()
{
}
public event EventHandler<DisplayMessage>? OnMessageRaised;
public static DisplayMessageBroker Instance => _messageBroker;
public void RaiseMessage(DisplayMessage message)
{
if (OnMessageRaised != null)
{
OnMessageRaised.Invoke(this, message);
}
}
}
}
The DisplayMessageBroker
also follows the Singleton pattern (which we learned about in Lesson 3.4) because we want every object in the game engine to use one shared instance of the broker. To implement the Singleton pattern (lines #10-14 and 18), we will:
- Make the constructor private, so it cannot be instantiated by any other class.
- Create a private static variable that holds the only instance of the
DisplayMessageBroker
. - Create a static
Instance
read-only property that returns the single instance of this class.
Then, we define a public event called OnMessageRaised
, which provides the place where subscribers can register their event handlers to respond to changes raised by our broker.
Finally, the RaiseMessage
method invokes any registered event handlers when it is called. Anywhere in the game engine, we can call DisplayMessageBroker.Instance.RaiseMessage
and that will fire the event and notify all subscribers that a new DisplayMessage
has been sent.
Now that we have our message broker, we need to call it show a DisplayMessage
. To verify this lesson only, I will update the GameSession
to use this broker to duplicate sending a game message.
using SimpleRPG.Game.Engine.Factories;
using SimpleRPG.Game.Engine.Models;
using SimpleRPG.Game.Engine.Services;
using System;
using System.Collections.Generic;
using System.Linq;
namespace SimpleRPG.Game.Engine.ViewModels
{
public class GameSession : IGameSession
{
private readonly World _currentWorld;
private readonly int _maximumMessagesCount = 100;
private readonly Dictionary<string, Action> _userInputActions = new Dictionary<string, Action>();
public Player CurrentPlayer { get; private set; }
public Location CurrentLocation { get; private set; }
public Monster? CurrentMonster { get; private set; }
public bool HasMonster => CurrentMonster != null;
public Trader? CurrentTrader { get; private set; }
public MovementUnit Movement { get; private set; }
public IList<DisplayMessage> Messages { get; } = new List<DisplayMessage>();
public GameSession(int maxMessageCount)
: this()
{
_maximumMessagesCount = maxMessageCount;
}
public GameSession()
{
InitializeUserInputActions();
CurrentPlayer = new Player
{
Name = "DarthPedro",
CharacterClass = "Fighter",
CurrentHitPoints = 10,
MaximumHitPoints = 10,
Gold = 1000,
Level = 1
};
_currentWorld = WorldFactory.CreateWorld();
Movement = new MovementUnit(_currentWorld);
CurrentLocation = Movement.CurrentLocation;
GetMonsterAtCurrentLocation();
if (!CurrentPlayer.Inventory.Weapons.Any())
{
CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(1001));
}
CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(2001));
CurrentPlayer.LearnRecipe(RecipeFactory.GetRecipeById(1));
CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(3001));
CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(3002));
CurrentPlayer.Inventory.AddItem(ItemFactory.CreateGameItem(3003));
}
public void OnLocationChanged(Location newLocation)
{
_ = newLocation ?? throw new ArgumentNullException(nameof(newLocation));
CurrentLocation = newLocation;
Movement.UpdateLocation(CurrentLocation);
GetMonsterAtCurrentLocation();
CompleteQuestsAtLocation();
GetQuestsAtLocation();
CurrentTrader = CurrentLocation.TraderHere;
}
public void AttackCurrentMonster(GameItem? currentWeapon)
{
if (CurrentMonster is null)
{
return;
}
if (currentWeapon is null)
{
AddDisplayMessage("Combat Warning", "You must select a weapon, to attack.");
return;
}
// player acts monster with weapon
CurrentPlayer.CurrentWeapon = currentWeapon;
var message = CurrentPlayer.UseCurrentWeaponOn(CurrentMonster);
AddDisplayMessage(message);
// if monster is killed, collect rewards and loot
if (CurrentMonster.IsDead)
{
OnCurrentMonsterKilled(CurrentMonster);
// get another monster to fight
GetMonsterAtCurrentLocation();
}
else
{
// if monster is still alive, let the monster attack
message = CurrentMonster.UseCurrentWeaponOn(CurrentPlayer);
AddDisplayMessage(message);
// if player is killed, move them back to their home and heal.
if (CurrentPlayer.IsDead)
{
OnCurrentPlayerKilled(CurrentMonster);
}
}
}
public void ConsumeCurrentItem(GameItem? item)
{
if (item is null || item.Category != GameItem.ItemCategory.Consumable)
{
AddDisplayMessage("Item Warning", "You must select a consumable item to use.");
return;
}
// player uses consumable item to heal themselves and item is removed from inventory.
CurrentPlayer.CurrentConsumable = item;
var message = CurrentPlayer.UseCurrentConsumable(CurrentPlayer);
AddDisplayMessage(message);
}
public void CraftItemUsing(Recipe recipe)
{
_ = recipe ?? throw new ArgumentNullException(nameof(recipe));
var lines = new List<string>();
if (CurrentPlayer.Inventory.HasAllTheseItems(recipe.Ingredients))
{
CurrentPlayer.Inventory.RemoveItems(recipe.Ingredients);
foreach (ItemQuantity itemQuantity in recipe.OutputItems)
{
for (int i = 0; i < itemQuantity.Quantity; i++)
{
GameItem outputItem = ItemFactory.CreateGameItem(itemQuantity.ItemId);
CurrentPlayer.Inventory.AddItem(outputItem);
lines.Add($"You craft 1 {outputItem.Name}");
}
}
AddDisplayMessage("Item Creation", lines);
}
else
{
lines.Add("You do not have the required ingredients:");
foreach (ItemQuantity itemQuantity in recipe.Ingredients)
{
lines.Add($" {itemQuantity.Quantity} {ItemFactory.GetItemName(itemQuantity.ItemId)}");
}
AddDisplayMessage("Item Creation", lines);
}
}
public void ProcessKeyPress(KeyProcessingEventArgs args)
{
_ = args ?? throw new ArgumentNullException(nameof(args));
var key = args.Key.ToUpper();
if (_userInputActions.ContainsKey(key))
{
_userInputActions[key].Invoke();
}
}
private void OnCurrentPlayerKilled(Monster currentMonster)
{
AddDisplayMessage("Player Defeated", $"The {currentMonster.Name} killed you.");
CurrentPlayer.CompletelyHeal(); // Completely heal the player
this.OnLocationChanged(_currentWorld.LocationAt(0, -1)); // Return to Player's home
}
private void OnCurrentMonsterKilled(Monster currentMonster)
{
var messageLines = new List<string>();
messageLines.Add($"You defeated the {currentMonster.Name}!");
CurrentPlayer.AddExperience(currentMonster.RewardExperiencePoints);
messageLines.Add($"You receive {currentMonster.RewardExperiencePoints} experience points.");
CurrentPlayer.ReceiveGold(currentMonster.Gold);
messageLines.Add($"You receive {currentMonster.Gold} gold.");
foreach (GameItem item in currentMonster.Inventory.Items)
{
CurrentPlayer.Inventory.AddItem(item);
messageLines.Add($"You received {item.Name}.");
}
AddDisplayMessage("Monster Defeated", messageLines);
}
private void GetMonsterAtCurrentLocation()
{
CurrentMonster = CurrentLocation.HasMonster() ? CurrentLocation.GetMonster() : null;
if (CurrentMonster != null)
{
AddDisplayMessage("Monster Encountered:", $"You see a {CurrentMonster.Name} here!");
DisplayMessageBroker.Instance.RaiseMessage(new DisplayMessage("Monster Encountered2:", $"You see a {CurrentMonster.Name} here!"));
}
}
private void GetQuestsAtLocation()
{
foreach (Quest quest in CurrentLocation.QuestsAvailableHere)
{
if (!CurrentPlayer.Quests.Any(q => q.PlayerQuest.Id == quest.Id))
{
CurrentPlayer.Quests.Add(new QuestStatus(quest));
AddDisplayMessage(quest.ToDisplayMessage());
}
}
}
private void CompleteQuestsAtLocation()
{
foreach (Quest quest in CurrentLocation.QuestsAvailableHere)
{
QuestStatus questToComplete =
CurrentPlayer.Quests.FirstOrDefault(q => q.PlayerQuest.Id == quest.Id &&
!q.IsCompleted);
if (questToComplete != null)
{
if (CurrentPlayer.Inventory.HasAllTheseItems(quest.ItemsToComplete))
{
// Remove the quest completion items from the player's inventory
CurrentPlayer.Inventory.RemoveItems(quest.ItemsToComplete);
// give the player the quest rewards
var messageLines = new List<string>();
CurrentPlayer.AddExperience(quest.RewardExperiencePoints);
messageLines.Add($"You receive {quest.RewardExperiencePoints} experience points");
CurrentPlayer.ReceiveGold(quest.RewardGold);
messageLines.Add($"You receive {quest.RewardGold} gold");
foreach (ItemQuantity itemQuantity in quest.RewardItems)
{
GameItem rewardItem = ItemFactory.CreateGameItem(itemQuantity.ItemId);
CurrentPlayer.Inventory.AddItem(rewardItem);
messageLines.Add($"You receive a {rewardItem.Name}");
}
AddDisplayMessage($"Quest Completed - {quest.Name}", messageLines);
// mark the quest as completed
questToComplete.IsCompleted = true;
}
}
}
}
private void InitializeUserInputActions()
{
_userInputActions.Add("W", () => Movement.MoveNorth());
_userInputActions.Add("A", () => Movement.MoveWest());
_userInputActions.Add("S", () => Movement.MoveSouth());
_userInputActions.Add("D", () => Movement.MoveEast());
_userInputActions.Add("ARROWUP", () => Movement.MoveNorth());
_userInputActions.Add("ARROWLEFT", () => Movement.MoveWest());
_userInputActions.Add("ARROWDOWN", () => Movement.MoveSouth());
_userInputActions.Add("ARROWRIGHT", () => Movement.MoveEast());
}
private void AddDisplayMessage(string title, string message) =>
AddDisplayMessage(title, new List<string> { message });
private void AddDisplayMessage(string title, IList<string> messages)
{
AddDisplayMessage(new DisplayMessage(title, messages));
}
public void AddDisplayMessage(DisplayMessage message)
{
this.Messages.Insert(0, message);
if (Messages.Count > _maximumMessagesCount)
{
Messages.Remove(Messages.Last());
}
}
}
}
In line #214, we grab the single instance of the DisplayMessageBroker
and call the RaiseMessage
method to show a duplicate message when we find a monster at the current location.
Making Page the Observer
Now that our view model raises a new DisplayMessage
event, we need to define our first observer/subscriber. We will do that in the GameScreen
page.
@page "/"
@inject IJSRuntime jsRuntime
@inject IGameSession ViewModel
<div @onkeydown="@KeyDown" tabindex="0" @ref="pageRoot">
<Row Margin="Margin.Is0" Style="height: 5vh; min-height: 32px">
<Column ColumnSize="ColumnSize.Is12" Style="background-color: lightgrey">
<Heading Size="HeadingSize.Is3">Simple RPG</Heading>
</Column>
</Row>
<Row Margin="Margin.Is0" Style="height: 60vh">
<Column ColumnSize="ColumnSize.Is4.OnWidescreen.Is12" Style="background-color: aquamarine">
<PlayerComponent Player="@ViewModel.CurrentPlayer" />
</Column>
<Column ColumnSize="ColumnSize.Is8.OnWidescreen.Is12" Style="background-color: beige">
<Row Margin="Margin.Is2.OnY">
<Column ColumnSize="ColumnSize.Is8.OnWidescreen.Is12">
<DisplayMessageListView Messages="@ViewModel.Messages" />
</Column>
<Column ColumnSize="ColumnSize.Is4.OnWidescreen.Is12">
<LocationComponent Location="@ViewModel.CurrentLocation" />
<MonsterComponent Monster="@ViewModel.CurrentMonster" />
<TraderComponent Trader="@ViewModel.CurrentTrader" Player="@ViewModel.CurrentPlayer"
InventoryChanged="@StateHasChanged" />
</Column>
</Row>
</Column>
</Row>
<Row Margin="Margin.Is0" Style="height: 33vh">
<Column ColumnSize="ColumnSize.Is4.OnWidescreen.Is12" Padding="Padding.Is2.OnY"
Style="background-color: burlywood">
<PlayerTabs Player="@ViewModel.CurrentPlayer" CraftItemClicked="@ViewModel.CraftItemUsing"
DisplayMessageCreated="@ViewModel.AddDisplayMessage" />
</Column>
<Column ColumnSize="ColumnSize.Is8.OnWidescreen.Is12" Style="background-color: lavender">
<Row Margin="Margin.Is2.OnY">
<Column ColumnSize="ColumnSize.Is8.OnWidescreen.Is12">
<CombatComponent WeaponList="@ViewModel.CurrentPlayer.Inventory.Weapons"
AttackClicked="@ViewModel.AttackCurrentMonster"
LocationHasMonster="@ViewModel.HasMonster"
ConsumableList="@ViewModel.CurrentPlayer.Inventory.Consumables"
ConsumeClicked="@ViewModel.ConsumeCurrentItem" />
</Column>
<Column ColumnSize="ColumnSize.Is4.OnWidescreen.Is12">
<MovementComponent Movement="@ViewModel.Movement"
LocationChanged="@ViewModel.OnLocationChanged" />
</Column>
</Row>
</Column>
</Row>
</div>
@code {
protected ElementReference pageRoot; // set the @ref for attribute
protected async override Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await jsRuntime.InvokeVoidAsync("SetFocusToElement", pageRoot);
}
}
protected override void OnInitialized()
{
DisplayMessageBroker.Instance.OnMessageRaised += OnGameMessageRaised;
}
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
private void KeyDown(KeyboardEventArgs args) =>
ViewModel.ProcessKeyPress(args.ToKeyProcessingEventArgs());
private void OnGameMessageRaised(object sender, DisplayMessage message) =>
ViewModel.AddDisplayMessage(message);
}
First, we override the page’s OnInitialized
method (lines #64-67). This method is called during the component startup lifecycle. It is a good place to enter one-time initialization code. In this method, we register the page’s event handler with the DisplayMessageBroker
.
Then, we define the OnGameMessageRaised
method (lines 73-74). All the event handler does is forward the DisplayMessage
to ViewModel.AddDisplayMessage
to get the new message added to the GameSession.Messages
list that gets displayed.
To get this code to compile, we need to also add the SimpleRPG.Game.Engine.Services
namespace to the _Imports.razor file (line #13), so that it is accessible to all Razor components.
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using SimpleRPG.Game
@using SimpleRPG.Game.Helpers
@using SimpleRPG.Game.Shared
@using SimpleRPG.Game.Engine.Models
@using SimpleRPG.Game.Engine.Services
@using SimpleRPG.Game.Engine.ViewModels
@using Blazorise
Now we can build and run our game again. If we move to a location with a monster in it (like the Farmer’s Field), we will see duplicate encounter methods. This proves that our message broker is sending events correctly and our observer on the GameScreen
page is receiving them.

As you may have noted, the code in this lesson fires an event in the GameSession
view model, listens to it on the page, and then forwards that message back to the GameSession.AddDisplayMessage
method. That is a round-about way to add a message in the GameSession
… since we can also just directly call AddDisplayMessage
.
We added the message broker call to GameSession
in this lesson just to prove that our broker, subject, and observer were all working together correctly. In the next lesson, we will remove this code again and have our new battle class use the message broker instead. This lesson was just preparing us for the next step in our combat refactoring.