We left the Player
and LivingEntity
classes very open ended and allow our code to just set properties for things like hit points and experience. However, there is actually logic that needs to be run when some of these properties change. So we are going to add methods with additional logic to handle these behaviors. This will force us to refactor some of our engine code, but provides better encapsulation of our classes by moving repetitive logic into these methods.
One thing we are not doing in this lesson is making these property setters private. We still want to create code for serialization in later lessons, so we are going to figure out how that works with JSON serialization (and many serializers work better with public properties). We will make any further refactoring changes that we need at that time.
LivingEntity Changes
First, we are going to refactor the way hit points are tracked. Rather than just updating the CurrentHitPoints
property, like we currently do. We are going to encapsulate some hit point behavior into the TakeDamage
, Heal
, and CompletelyHeal
methods, with the following changes to the LivingEntity
class:
using System;
namespace SimpleRPG.Game.Engine.Models
{
public abstract class LivingEntity
{
public string Name { get; set; } = string.Empty;
public int CurrentHitPoints { get; set; }
public int MaximumHitPoints { get; set; }
public int Gold { get; set; }
public int Level { get; set; }
public Inventory Inventory { get; } = new Inventory();
public bool IsDead => CurrentHitPoints <= 0;
public void TakeDamage(int hitPointsOfDamage)
{
if (hitPointsOfDamage > 0)
{
CurrentHitPoints -= hitPointsOfDamage;
}
}
public void Heal(int hitPointsToHeal)
{
if (hitPointsToHeal > 0)
{
CurrentHitPoints += hitPointsToHeal;
if (CurrentHitPoints > MaximumHitPoints)
{
CurrentHitPoints = MaximumHitPoints;
}
}
}
public void CompletelyHeal()
{
CurrentHitPoints = MaximumHitPoints;
}
public void ReceiveGold(int amountOfGold)
{
if (amountOfGold > 0)
{
Gold += amountOfGold;
}
}
public void SpendGold(int amountOfGold)
{
if (amountOfGold > Gold)
{
throw new ArgumentOutOfRangeException(nameof(amountOfGold), $"{Name} only has {Gold} gold, and cannot spend {amountOfGold} gold");
}
if (amountOfGold > 0)
{
Gold -= amountOfGold;
}
}
}
}
The hit point changes involved:
- Providing an
IsDead
derived property (line #19) that tests whether theCurrentHitPoints
is less than or equal to 0. - The
TakeDamage
method (lines #21-27) decrements the entity’s hit points by the specified damage, as long as it’s a positive amount. - The
Heal
method (lines #29-40) increments the entity’s hit points by the specified amount (as long as it’s a positive amount as well). But it caps the total number of healed points by theMaximumHitPoints
property. - The
CompletelyHeal
method (lines #42-45) heals the entity to the full amount –MaximumHitPoints
.
After the hit point methods, we can look at the gold management methods:
- The
ReceiveGold
method (line #47-53) increments the entity’s gold by the specified amount. - The
SpendGold
method (line #55-66) checks whether the entity has enough funds and if it doesn’t, we throw anArgumentOutOfRange
exception. If the entity has enough funds, then we decrement the gold amount.
Player Changes
We will update the Player
class to manage setting the experience points and character level.
using System.Collections.Generic;
namespace SimpleRPG.Game.Engine.Models
{
public class Player : LivingEntity
{
public string CharacterClass { get; set; } = string.Empty;
public int ExperiencePoints { get; set; }
public IList<QuestStatus> Quests { get; set; } = new List<QuestStatus>();
public void AddExperience(int experiencePoints)
{
if (experiencePoints > 0)
{
ExperiencePoints += experiencePoints;
SetLevelAndMaximumHitPoints();
}
}
private void SetLevelAndMaximumHitPoints()
{
int originalLevel = Level;
Level = (ExperiencePoints / 100) + 1;
if (Level != originalLevel)
{
MaximumHitPoints = Level * 10;
}
}
}
}
The AddExperience
method first increments the experience points by the specified amount. Then it has logic to set the level based on 1 level per 100 experience points. And when the player achieves a new level, we update the MaximumHitPoints
for the player as well — the MaximumHitPoints
calculation is 10 hit points per level.
With these changes, we have completed the functionality that we wanted. Now we need to update the view models to use these new methods (rather than direct access to the properties).
View Model Changes
First, we have two minor changes to the TraderViewModel
class:
using Microsoft.AspNetCore.Components;
using SimpleRPG.Game.Engine.Models;
using System;
namespace SimpleRPG.Game.Engine.ViewModels
{
public class TraderViewModel
{
public Trader? Trader { get; set; } = null;
public Player? Player { get; set; } = null;
public string ErrorMessage { get; private set; } = string.Empty;
public EventCallback InventoryChanged { get; set; }
public void OnSellItem(GameItem item)
{
_ = item ?? throw new ArgumentNullException(nameof(item));
if (Player != null && Trader != null)
{
Player.ReceiveGold(item.Price);
Trader.Inventory.AddItem(item);
Player.Inventory.RemoveItem(item);
InventoryChanged.InvokeAsync(null);
}
}
public void OnBuyItem(GameItem item)
{
_ = item ?? throw new ArgumentNullException(nameof(item));
if (Player != null && Trader != null)
{
ErrorMessage = string.Empty;
if (Player.Gold >= item.Price)
{
Player.SpendGold(item.Price);
Trader.Inventory.RemoveItem(item);
Player.Inventory.AddItem(item);
InventoryChanged.InvokeAsync(null);
}
else
{
ErrorMessage = "Error: you do not have enough gold.";
}
}
}
}
}
During OnSellItem
, we use the Player.ReceiveGold
method to get the sales proceeds. During OnBuyItem
, we use the Player.SpendGold
method to remove that amount from the player.
Then we need to make similar changes to the GameSession
class:
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 IDiceService _diceService;
private readonly int _maximumMessagesCount = 100;
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(IDiceService? diceService = null)
{
_diceService = diceService ?? DiceService.Instance;
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));
}
}
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(Weapon? currentWeapon)
{
if (CurrentMonster is null)
{
return;
}
if (currentWeapon is null)
{
AddDisplayMessage("Combat Warning", "You must select a weapon, to attack.");
return;
}
// Determine damage to monster
int damageToMonster = _diceService.Roll(currentWeapon.DamageRoll).Value;
if (damageToMonster == 0)
{
AddDisplayMessage("Player Combat", $"You missed the {CurrentMonster.Name}.");
}
else
{
CurrentMonster.TakeDamage(damageToMonster);
AddDisplayMessage("Player Combat", $"You hit the {CurrentMonster.Name} for {damageToMonster} points.");
}
// If monster if killed, collect rewards and loot
if (CurrentMonster.IsDead)
{
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);
// Get another monster to fight
GetMonsterAtCurrentLocation();
}
else
{
// If monster is still alive, let the monster attack
int damageToPlayer = _diceService.Roll(CurrentMonster.DamageRoll).Value;
if (damageToPlayer == 0)
{
AddDisplayMessage("Monster Combat", "The monster attacks, but misses you.");
}
else
{
CurrentPlayer.TakeDamage(damageToPlayer);
AddDisplayMessage("Monster Combat", $"The {CurrentMonster.Name} hit you for {damageToPlayer} points.");
}
// If player is killed, move them back to their home.
if (CurrentPlayer.IsDead)
{
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 GetMonsterAtCurrentLocation()
{
CurrentMonster = CurrentLocation.HasMonster() ? CurrentLocation.GetMonster() : null;
if (CurrentMonster != null)
{
AddDisplayMessage("Monster Encountered:", $"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));
var messageLines = new List<string>
{
quest.Description,
"Items to complete the quest:"
};
foreach (ItemQuantity q in quest.ItemsToComplete)
{
messageLines.Add($" {ItemFactory.CreateGameItem(q.ItemId).Name} (x{q.Quantity})");
}
messageLines.Add("Rewards for quest completion:");
messageLines.Add($" {quest.RewardExperiencePoints} experience points");
messageLines.Add($" {quest.RewardGold} gold");
foreach (ItemQuantity itemQuantity in quest.RewardItems)
{
messageLines.Add($" {itemQuantity.Quantity} {ItemFactory.CreateGameItem(itemQuantity.ItemId).Name} (x{itemQuantity.Quantity})");
}
AddDisplayMessage($"Quest Added - {quest.Name}", messageLines);
}
}
}
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
foreach (ItemQuantity itemQuantity in quest.ItemsToComplete)
{
for (int i = 0; i < itemQuantity.Quantity; i++)
{
CurrentPlayer.Inventory.RemoveItem(
CurrentPlayer.Inventory.Items.First(
item => item.ItemTypeID == itemQuantity.ItemId));
}
}
// 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 AddDisplayMessage(string title, string message) =>
AddDisplayMessage(title, new List<string> { message });
private void AddDisplayMessage(string title, IList<string> messages)
{
var message = new DisplayMessage(title, messages);
this.Messages.Insert(0, message);
if (Messages.Count > _maximumMessagesCount)
{
Messages.Remove(Messages.Last());
}
}
}
}
For the first change in the attack method, call AddExperience
and ReceiveGold
when the monster is defeated.
Then, if the player is defeated, we use the CompletelyHeal
method to reset the player’s hit points.
Finally when we complete a quest, we use AddExperience
and ReceiveGold
again to give the player the rewards from the quest.
These refactorings in the GameSession
are small one-line changes, but they change the behavior of experience, hit points, and gold in our system. We were able to easily add more logic to these aspects of the game when we need to.
Unit Testing with [Theory]
The bulk of our unit tests until this point have used the [Fact]
attribute from xUnit. This runs one test and its validations. However, when we want to test with several variations, rather than write each individual test and just varying the data and expected values, we can use the [Theory]
and [InlineData]
attributes from xUnit to simplify test creation.
Let’s take a look a one of the tests we wrote for this lesson in the PlayerTests.cs file:
using SimpleRPG.Game.Engine.Models;
using System;
using System.Collections.Generic;
using Xunit;
namespace SimpleRPG.Game.Engine.Tests.Models
{
public class PlayerTests
{
...
[Theory]
[InlineData(10, 4, 6)]
[InlineData(4, 8, -4)]
[InlineData(6, -2, 6)]
public void TakeDamage(int currHP, int damage, int expected)
{
// arrange
var p = new Player
{
Name = "Test",
Level = 1,
CurrentHitPoints = currHP,
MaximumHitPoints = 10
};
// act
p.TakeDamage(damage);
// assert
Assert.Equal(expected, p.CurrentHitPoints);
Assert.Equal(10, p.MaximumHitPoints);
}
...
}
}
This test method validates the TakeDamage
method works as expected. Notice the structure of this test as opposed to our other unit test methods:
- First, the test method has parameters for the player’s current hit points, the damage value, and the expected result of hit points. These parameters are used in setting up the test, running the
TakeDamage
action, and asserting the expected value. - Then the test uses the
[Theory]
attribute to tell xUnit that this is actually a set of tests, rather than a single one. - Finally, the
[InlineData]
attribute corresponds to each individual test case and provides the data for each one. The set of parameters in this attribute correspond to the parameters passed into the test method. - With all of this in place, this test method actually runs 3 times with the different data values, and validates that all three tests calculate the expected hit point value.
Using this xUnit feature lets you write a lot of test variations without needing to duplicate test code. This works well when dealing with mathematic or highly formulaic tests.
In conclusion
We can now build and run our game again. We will notice that the player in initialized correctly with gold, level, and default hit points. As we play the game, kill some monsters, and gain experience and gold, we can see the behavior we coded in this lesson — the player will accumulate experience, move to level 2, and get a new maximum hit points total.

We will look at incorporating game actions in our next lessons…