Roleplaying games are complex systems almost by definition and certainly not a genre you would generally recommend for someone who is only starting on their path to becoming a game programmer. There are plenty of ways you can shoot yourself in the foot by making unsound architectural choices that impede the project from the beginning. Unfortunately, even many popular tutorials tumble into one or more of these pitfalls.
The RPG Programming Pitfalls article series aims to discuss reliable and reusable ways to solve problems common in roleplaying game development (and other genres too). The key is to provide solutions that don’t make you want to curse your past self into the deepest pit of that-seemed-like-a-good-idea-then hell, and these are ways I’ve found to be the most optimal after working on half a dozen different RPGs.
The articles' code will be in C#, and any engine-specific things in Unity, but the general approach is applicable to most programming languages and engines.
This first article in the series is about stats, which form the core of any RPG. In fact, most games end up using some system for tracking various values, but in RPGs, they are central to the mechanics and very prominently visible.
In the article, we’ll look at the following topics:
- What stats are and how they are often implemented variables
- The issues with the above approach
- Using a Dictionary to create a more flexible implementation
- Adding some extra convenience
- Using the solution with Unity engine
What we usually seek to replicate is something like this:
Character sheet. (2023, June 30). In Wikipedia. https://en.wikipedia.org/wiki/Character_sheet
The intuitive approach is to start breaking down the character’s different attributes into variables like this:
public int Strength;
public int Dexterity;
public int Constitution;
public int HitPoints;
public int ProficiencyBonus;
public int Attack => Strength + ProficiencyBonus;
And for a while, long enough for the needs of most tutorials, everything would be fine. You just type out the needed attributes and abilities into neat little variable fields and sometimes even a property like the Attack above. But then you realise, you will need more than one variable for Hit Points, as you need to track both the current and the maximum for that.
public int MaxHitPoints;
public int CurrentHitPoints;
And if your game includes Mana Points, or Stamina Points or something similar, you need to do the same for them. That’s still pretty manageable, right? And then you start implementing the spells and find this in the design document:
Feebleness
Lowers the Strength attribute of the target character by 2 for the duration of the spell.
A quick glance over the spells reveals that there are similar spells for Dexterity and Constitution, and others too. So, now you will need to do the same for all the attributes! Or maybe there’s a better solution?
Stat Class
Instead of having at least two (or more if you also need to keep track of minimum values) variables for each attribute, you can have a custom class that keeps track of the persistent and current state of a character’s stat.
public class Stat
{
// Fields
private string _name;
private int _currentValue;
private int _maxValue;
private int _minvalue;
// Public Getters
public string Name => _name;
public int CurrentValue => _currentValue;
public int MaxValue => _maxValue;
public int MinValue => _minvalue;
// Events
private event System.Action<int> _onValueChanged;
public event System.Action<int> OnValueChanged
{
add
{
_onValueChanged += value;
}
remove
{
_onValueChanged -= value;
}
}
// Constructors
public Stat(string name, int value, int minValue, int maxValue)
{
_name = name;
_minvalue = minValue;
_maxValue = maxValue;
// Set the value through the SetValue method so it's guaranteed to be between min, max
SetValue(value);
}
/// <summary>
/// The most commonly used constructor that defaults the value to the max value
/// </summary>
/// <param name="name">Name of the stat</param>
/// <param name="value">Current and max value</param>
public Stat(string name, int value) : this(name, value, 0, value) { }
// Methods
/// <summary>
/// Adjust the current value, used often when you don't really need to know the stat's boundaries (e.g. taking damage)
/// </summary>
/// <param name="amount"></param>
public void Adjust(int amount)
{
SetValue(_currentValue + amount);
}
/// <summary>
/// Sets the current value and clamps it between min, max
/// </summary>
/// <param name="newValue">The value to set</param>
public void SetValue(int newValue)
{
// If nothing has changed, don't do anything
if (newValue == _currentValue)
return;
_currentValue = Mathf.Clamp(newValue, _minvalue, _maxValue);
_onValueChanged?.Invoke(_currentValue);
}
/// <summary>
/// Resets the value to its maximum. Often used in effects like healing or resetting a character to their default state.
/// </summary>
public void ResetToMax()
{
SetValue(_maxValue);
}
/// <summary>
/// Resets the value to the minimum value (usually 0). Useful for any stat that is gained temporarily.
/// </summary>
public void ResetToMin()
{
SetValue(_minvalue);
}
}
Above, we define three values for each stat: current, minimum and maximum. When you need to modify the current value of any stat (for example, when a character takes damage), you call the Adjust method. Inside the Adjust(), we call SetValue() method with the current value plus the amount you want to modify, and the latter takes care of clamping the result between the minimum and the maximum. SetValue() also calls the OnValueChanged event to let everyone know we’ve changed the current value. Very handy for displaying a damage floater, for example.
Here’s how the above spell might look like in the code:
public class StrengthDebuff
{
public int AdjustValue = -2;
public void OnStartEffect(NewbieCharacterSheet target)
{
target.Strength.SetValue(AdjustValue);
}
public void OnEndEffect(NewbieCharacterSheet target)
{
target.Strength.ResetToMax();
}
}
Much better, right? There are still some issues there, however.
- You need to make an almost identical class for each one of the attributes to accommodate all the possible stat-lowering spells.
- If another effect alters the Strength attribute during the spell’s duration, it will be negated when this spell ends, as the attribute is returned to its original value.
- The value is clamped to the original maximum Strength, so you must use a different solution for a spell that increases its target’s Strength.
Let’s concentrate on the first issue for now, as solving it will also solve a plethora of other problems too.
Items and Other Stat Boosters
Even if your game doesn’t include stat-altering spells, it almost certainly will have items and equipment; in most ways, they are identical. A sword is a spell that starts its effect when you equip it and ends when you unequip it. So when we want to know what our character’s actual Strength is, taking into account all the gear they’re wearing, we need something like this:
public Dictionary<EquipmentSlot, NewbieItem> EquippedItems;
public int GetStrengthValue()
{
int returnValue = Strength.CurrentValue;
foreach (var slot in EquippedItems.Keys)
{
var item = EquippedItems[slot];
if (item != null)
{
returnValue += item.StrengthModifier;
}
}
return returnValue;
}
Here, we iterate through our equipped items, tally the final Strength score, and return it. We could pretty easily add another loop to accommodate for any status effects. But the problem here is that we need a separate function for every attribute, which means many potentially buggy copy-pasta. The same goes for any instance involving changing or reading an arbitrary attribute.
And if we ever want to remove or add another one… Well, that’s going to be a pain.
The Dictionary Approach
Instead of a number of different fields for all the stats that comprise a character sheet, we can have just one (or, realistically, a bunch, but still less than with the previous architecture): a Dictionary.
Meet the new (and greatly improved!) Character Sheet:
public class CharacterSheet
{
private string _name;
private Dictionary<string, Stat> _stats;
public Stat this[string key]
{
get
{
if (!_stats.ContainsKey(key))
{
throw new KeyNotFoundException($"Character sheet for {_name} does not contain stat {key}!");
}
return _stats[key];
}
}
public int GetValue(string key)
{
return this[key].CurrentValue;
}
}
Now we can have a simple item class:
public class Item
{
private Dictionary<string, int> _statModifiers;
public Item()
{
_statModifiers = new Dictionary<string, int>();
}
public int GetModifier(string stat)
{
if (_statModifiers.ContainsKey(stat))
{
return _statModifiers[stat];
}
return 0;
}
}
And get any attribute with the same method:
// In the long term, it would be better to have the inventory as a separate class
private Dictionary<EquipmentSlot, Item> _equippedItems;
public int GetValue(string key)
{
int returnValue = this[key].CurrentValue;
foreach (var slot in _equippedItems.Keys)
{
var item = _equippedItems[slot];
if (item != null)
{
returnValue += item.GetModifier(key);
}
}
return returnValue;
}
For example:
var strength = characterSheet.GetValue("Strength");
We could also loop through all the possible status effects in the GetValue() method to account for the spells mentioned, but I’ll get deeper into that in an upcoming part of the series.
Easier Access
Using strings for keys has its problems, but especially in Unity, I’ve found it to be the least worst of all the options. In most cases, using an Enum for the key would be faster and less error-prone, but Unity serializes Enums as integers, which causes all the serialized objects to break if you need to alter the Enum (other than adding new elements to the end).
While comparing and hashing strings is relatively slow, in most cases and on most platforms, the performance hit will not be noticeable. If you need to access an attribute a ton of times each frame, you can also cache the Stat variable and not need to fetch it every time.
Another drawback of strings is that you’ll not get a compile error if you accidentally mistype the name of the attribute (“strenght” vs “strength”), and that can lead to hard-to-find bugs. A simple solution for that is to store the names in separate variables you reference, instead of literal strings. Here are examples of these improvements in action:
public const string ATTRIBUTE_STRENGTH = "Strength";
public const string ATTRIBUTE_DEXTERITY = "Dexterity";
public const string ATTRIBUTE_HIT_POINTS = "Hit Points";
private Stat _hitpointsStat;
public Stat HitPoints
{
get
{
if (_hitpointsStat == null)
_hitpointsStat = _stats[ATTRIBUTE_HIT_POINTS];
return _hitpointsStat;
}
}
public int Strength => GetValue(ATTRIBUTE_STRENGTH);
public int Dexterity => GetValue(ATTRIBUTE_DEXTERITY);
I’ll end this article with a simple way to create the character sheets by reading them from a .CSV file. But first, we need to add methods to the Character Sheet class to add new stats:
public CharacterSheet()
{
_stats = new Dictionary<string, Stat>();
}
public void SetValue(string key, int value)
{
// Notice that this method has slightly different functionality depending on whether the sheet contains the key or not.
// If the stat is a new one, the value is used for both current and max values, but if not, only the current value is set
// If we already have the stat, replace the value
if (_stats.ContainsKey(key))
{
var stat = _stats[key];
stat.SetValue(value);
}
else
// Otherwise, create a new stat
{
_stats.Add(key, new Stat(key, value));
}
}
And here’s the static method that returns a list of character sheets from the file:
public static List<CharacterSheet> ReadCSV(string path)
{
var characterSheets = new List<CharacterSheet>();
var lines = new List<string> (File.ReadAllLines(path));
// Internal helper function to get the first line of the array and split it into cells
List<string> PopAndSplit()
{
var items = lines[0].Split(',', StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToList();
lines.RemoveAt(0);
return items;
}
// First line has the stat names, split and trim to get a list of keys
var keys = PopAndSplit();
while (lines.Count > 0)
{
// Get the values
var values = PopAndSplit();
var characterSheet = new CharacterSheet();
// Assume the name of the character is always first
characterSheet._name = values[0];
// Loop through the keys, if there are more values on any of the rows than on the first row, those values will be ignored
for (int i = 1; i < keys.Count; i++)
{
// Check that the value is an integer and add it to the sheet
if(int.TryParse(values[i], out var value))
{
characterSheet.SetValue(keys[i],value );
}
else
{
throw new InvalidDataException(
$"Value for stat {keys[i]} for character {characterSheet._name} is not an integer: {values[i]}!");
}
}
characterSheets.Add(characterSheet);
}
return characterSheets;
}
And a short example .CSV file:
Name, Hit Points, Attack Bonus, Strength, Dexterity
Hero, 100, 3, 15, 12
Orc, 35, 1, 12, 9
Skeleton, 30, 1, 10, 13
Unity Considerations
If, like me, you’re using Unity, you might want the CharacterSheet class to be, for example, a ScriptableObject and the stats to serialize with the built-in serializer. There are a couple of options: either serialize them as a list or use an extension that lets you serialize dictionaries (Odin Inspector, for one, comes with such functionality).
I would still recommend against serializing the CharacterSheets in Unity. As the amount of data grows, dealing with the files becomes increasingly cumbersome (although the new search tools do alleviate this quite a bit). Scriptable Objects in Unity are also serialized during play mode, so you’ll always need to remember to instantiate them to avoid data corruption. If you use external data, sharing with others is often easier (e.g., in Google Sheets).
But in case you still want to use Scriptable Objects, here’s a version of the Character Sheet using that:
[CreateAssetMenu(fileName = "New Character Sheet", menuName = "Character Sheet", order = 0)]
public class SOCharacterSheet : ScriptableObject
{
private List<Stat> _stats;
private Inventory _inventory;
public Stat this[string key] => Get(key);
private Stat Get(string key)
{
return _stats.Find(stat => stat.Name == key);
}
public bool HasStat(string key)
{
return _stats.Exists(stat => stat.Name == key);
}
public void SetValue(string key, int value)
{
// Notice that this method has slightly different functionality depending on whether the sheet contains the key or not.
// If the stat is a new one, the value is used for both current and max values, but if not, only the current value is set
// If we already have the stat, replace the value
if (HasStat(key))
{
var stat = Get(key);
stat.SetValue(value);
}
else
// Otherwise, create a new stat
{
_stats.Add(new Stat(key, value));
}
}
public int GetValue(string key)
{
int returnValue = this[key].CurrentValue;
foreach (var item in _inventory.GetEquippedItems())
{
returnValue += item.GetModifier(key);
}
return returnValue;
}
}
That’s it for this article! I hope you found this useful and avoid some headaches when creating your own RPGs!
In the next part of this series, we’ll be delving deep into damage! If you want to get notified when it (or any of the subsequent articles) goes up, consider subscribing by leaving your email in the box at the top. It won’t be shared with anyone and is only used to inform you of updates to this site.