Design Docs - Thinking about Characters - Part 2

I have been listening to a lot of various OSTs of the Final Fantasy series lately. Fittingly (is that a word?), since I have been playing Final Fantasy 6 as my "primary" game, a particular theme that has stood out to me is Celes's Theme.

Celes from Final Fantasy 6

 

For your convenient listening, feel free to click on the YouTube video below:

Play the theme

I'm not a musician, so I can't really give you any educational, theoretical, or artistic reason as to why I like this piece so much. I'll try to though.

The most important thing to know for those who haven't played Final Fantasy 6 is that Celes is what we commonly consider in my opinion, a secondary protagonist in the game. Someone like Terra for instance, is what everyone would commonly refer to as a primary protagonist. Although, I disagree with that.

Celes is actually written in a way that she can be a primary protagonist should you play the game in a way where she is your main party member. This is evident post-Terra-turned-Esper event.

Celes is special in that she was unwillingly forced to become a Magitek Knight (General) of the empire of Gestahl early in her life. After a turn of events, she eventually sides with our heroes in the efforts to overthrow the empire. Typical stuff, right?

Listen to the music! The music definitely didn't describe what I just said about Celes, right? In the game, Celes was seen as a character who was independent with a lot of pride and dignity. As the game progresses, we start to see Celes is much more than that as a character. She develops feelings for Locke (a contrast to her personality), and goes through a series of events which convinces us, the players that she is completely on the side of the Returners.

Adhering to a few tropes, Celes isn't the type of character to outright show her vulnerabilities. She's the type that will hide it and instead put on a hard exterior.

The piece is most memorably played during the scene at the Opera House, where Celes is forced to perform as the body-double of Maria, in an effort to speak to Setzer about getting access to the Airship. In this scene, we see that when Celes is performing the act, it seems like there is a personal realization that up until now, she has had not much control on her own life. During her performance, the theme is played, and you can feel that for once, she has a choice in what she can do.

All this came to me because I have been thinking a lot about gender roles in video games -- especially in older JRPGs. I've been thinking carefully about how female characters are portrayed and their default job classes assigned. I don't want to get on that tangent here, but I'll save it as a discussion for another day.

Anyway, onto character system design.

Finding Code

We now have a few new repositories. Most of the discussion here will be centered around the new code repository called jrpg-character-system. The other code repository called jrpg-item-components will be common amongst jrpg-inventory-system and jrpg-character-system.

Bringing a Character to Life (Sort of)

We're going to heavily modify our Character object to hold a set of statistics, so our Character is still the "main entity" to be used in a game to do all operations which we'd typically find in a traditional JRPG.

Before we get into adding more things to our current Character found in our inventory system, let's actually create a new project called jrpg-character-system, and move some of our existing code into there.

So, to start out, let's transfer over our existing character-related classes Character, BodyPart, CharacterBody and CharacterStatistics to this new project.

Another project will need to be created called jrpg-item-components will need to be created to prevent any circular references between jrpg-character-system and jrpg-inventory-system. This project will just house our ItemInfo and ItemName classes for now.

The goal is to have jrpg-character-system to contain basic character code which will for now, at least have:

  1. A name
  2. A body to equip, and unequip items.
  3. A set of statistics

We get (1) and (2) for free already from our adventures in creating an inventory system. For (3), there is a set of very basic statistics system in which we should probably just scrap and rewrite. Let's do that now!

Statistics

The first goal is to come up with a set of standard statistics. These statistics should be normally what we would find in a JRPG game. Making things easy for myself, I decided to use Final Fantasy 6 as inspiration.

Here is what I decided:

  • Level
  • HP Current
  • HP Max
  • MP Current
  • MP Max
  • Strength
  • Speed
  • Stamina
  • Magic
  • Attack
  • Defense
  • Evasion
  • Magic Defense
  • Magic Evasion
  • Experience

For detailed explanations on these staistics, I found this to be very helpful: Final Fantasy VI Stats.

I've deliberately stayed very safe here and just chose "traditional stats". The goal for me is to complete a game, and we can be creative later. Besides, how would you explain what is "Hackitude"?

Hackitude? Seriously?

 

All statistics can be decribed as a type called StatisticType. The abstraction is a basic enum to define what the type is, and a set of corresponding max, and default values for each type.

using System.Collections.Generic;
namespace Jrpg.CharacterSystem
{
    public enum StatisticType
    {
        Level = 0,
        HpCurrent,
        HpMax,
        MpCurrent,
        MpMax,
        Strength,
        Speed,
        Stamina,
        Magic,
        Attack,
        Defense,
        Evasion,
        MagicDefense,
        MagicEvasion,
        Experience
    }

    public class StatisticTypeCollection
    {
        public static List<StatisticType> All = new List<StatisticType>
        {
            StatisticType.Level,
            StatisticType.HpCurrent,
            StatisticType.HpMax,
            StatisticType.MpCurrent,
            StatisticType.MpMax,
            StatisticType.Strength,
            StatisticType.Speed,
            StatisticType.Stamina,
            StatisticType.Magic,
            StatisticType.Attack,
            StatisticType.Defense,
            StatisticType.Evasion,
            StatisticType.MagicDefense,
            StatisticType.MagicEvasion,
            StatisticType.Experience
        };

        public static Dictionary<StatisticType, int> MaxValues = new Dictionary<StatisticType, int>
        {
            { StatisticType.Level, 100},
            { StatisticType.HpCurrent, 9999},
            { StatisticType.HpMax, 9999 },
            { StatisticType.MpCurrent, 999 },
            { StatisticType.MpMax, 999 },
            { StatisticType.Strength, 255 },
            { StatisticType.Speed, 255 },
            { StatisticType.Stamina, 255 },
            { StatisticType.Magic, 255 },
            { StatisticType.Attack, 255 },
            { StatisticType.Defense, 255 },
            { StatisticType.Evasion, 255 },
            { StatisticType.MagicDefense, 255 },
            { StatisticType.MagicEvasion, 255 },
            { StatisticType.Experience, 255 }
        };

        public static Dictionary<StatisticType, int> DefaultValues = new Dictionary<StatisticType, int>
        {
            { StatisticType.Level, 1},
            { StatisticType.HpCurrent, 30},
            { StatisticType.HpMax, 30 },
            { StatisticType.MpCurrent, 1 },
            { StatisticType.MpMax, 1 },
            { StatisticType.Strength, 1 },
            { StatisticType.Speed, 1 },
            { StatisticType.Stamina, 1 },
            { StatisticType.Magic, 1 },
            { StatisticType.Attack, 1 },
            { StatisticType.Defense, 1 },
            { StatisticType.Evasion, 1 },
            { StatisticType.MagicDefense, 1 },
            { StatisticType.MagicEvasion, 1 },
            { StatisticType.Experience, 0 }
        };
    }
}

Stickin' to the classic 255 max!

Since each statistic for a character can be a different value from one another at any given moment, we need to find a method to distinguish each statistic from one another.

We can create a Statistic object to encapsulate a current value for a particular StatisticType. Doing so gives us a few benefits as opposed to storing a direct mapping of a StatisticType to an integer. Having a dedicated setter here allows a statistic to be set with custom logic. One example of a setter implementation is to never surpass the defined max value a given statistic has.

namespace Jrpg.CharacterSystem
{
    public class Statistic
    {
        public string Name { get; set; }
        public StatisticType Type { get; }

        private int _currentValue;
        public int CurrentValue
        {
            get
            {
                return _currentValue;
            }
            set
            {
                if (value > MaxValue)
                {
                    _currentValue = MaxValue;
                }
                else
                {
                    _currentValue = value;
                }
            }
        }

        public int MaxValue { get; }
        
        public Statistic(StatisticType type, int maxValue)
        {
            Type = type;
            MaxValue = maxValue;
        }
    }
}

Experience

Let's go back to modifying the Character.cs class.

As mentioned previously, we have a dedicated function (expression) to calculate experience needed to attain the next level. This is based on Gen1 Pokemon games.

private int NextExperienceThreshold(int currentLevel)
{
  // Inspired by Gen1 Pokemon's experience curve.
  return (int)((4 * Math.Pow(currentLevel, 3)) / 5);
}

Our function Character::NextExperienceThreshold will calculate the experience points the character must have in order to attain the next level based on their current level. Within the Character class, we will have Character::AddExperience which will add experience points, level up the character and adjust their statistics as needed.

public bool AddExperience(int experience)
{
  Statistics[StatisticType.Experience].CurrentValue += experience;

  bool result = false;
  while (MaybeLevelUp() == true)
  {
    result = true;
  }

  return result;
}

Character::MaybeLevelUp will always be called after adding experience points. It will return a bool. The return value of false will be returned if the character hasn't enough experience to level up, or true if the character has leveled up based on the amount of experience added.

private bool MaybeLevelUp()
{
  if (Statistics[StatisticType.Experience].CurrentValue < _nextExpLimit)
  {
    return false;
  }

  var currentLevel = Statistics[StatisticType.Level].CurrentValue;
  _nextExpLimit = NextExperienceThreshold(currentLevel);

  currentClass.LevelUp();

  return true;
}

Why does AddExperience call MaybeLevelUp in a while loop? This is actually to take into consideration that a character who is of very low level in a party for example, receives a massive amount of experience in a single event. The experience acquired may take the character up multiple levels, and should be considered.

Adjusting Statistics and Scaling

All the statistics belonging to the character can be encapsulated into a single class. Here, I am taking advantage of some clairvoyance, and have decided to implement some of the beginnings of a job class system. For the sake of this discussion, let's not think too much about that now since there are a ton of corner cases involved to build something like that properly.

For now, let's think of this as a simple "manager class" which happens to be named "Freelancer". 😄

using System;
using System.Collections.Generic;
using Jrpg.CharacterSystem.Scalers;
using Jrpg.CharacterSystem.Scalers.Freelancer;

namespace Jrpg.CharacterSystem.Classes
{
    public class Freelancer
    {
        private Dictionary<StatisticType, Statistic> Statistics;

        private IStatisticScaler levelScaler;
        private IStatisticScaler hpScaler;
				// Truncated
        private IStatisticScaler magicDefenseScaler;
        private IStatisticScaler magicEvasionScaler;

        public Freelancer(Dictionary<StatisticType, Statistic> statistics)
        {
            Statistics = statistics;

            levelScaler = new LevelScaler();
            hpScaler = new HpScaler();
						// Truncated
            magicDefenseScaler = new MagicDefenseScaler();
            magicEvasionScaler = new MagicEvasionScaler();
        }

        private Statistic NextLevel()
        {
            return levelScaler.NewStatistic(Statistics);
        }

        private Statistic NextHpMax()
        {
            return hpScaler.NewStatistic(Statistics);
        }

				// ... Other methods here

        private Statistic NextMagicEvasion()
        {
            return magicEvasionScaler.NewStatistic(Statistics);
        }

        public string ClassName()
        {
            return "Freelancer";
        }

        public void LevelUp()
        {
            Statistics[StatisticType.Level] = NextLevel();
            Statistics[StatisticType.HpMax] = NextHpMax();
						// ... Truncated
            Statistics[StatisticType.MagicDefense] = NextMagicDefense();
            Statistics[StatisticType.MagicEvasion] = NextMagicEvasion();
        }
    }
}

Looking at our Freelancer class, we see a bunch of properties of IStatisticScaler type. What are these, exactly?

In order to help with statistics growth of a character, the thought behind of IStatisticScaler was to account that statistics grow at different rates, or grow dependning on a composition of other other statistics.

using System.Collections.Generic;
namespace Jrpg.CharacterSystem.Scalers
{
    public interface IStatisticScaler
    {
        Statistic NewStatistic(Dictionary<StatisticType, Statistic> statistic);
        Statistic Copy(Statistic toCopy);
    }
}

For now, the interface has just two interface methods, which must be implemented to determine the next statistic for a character's level. A sample implementation of this interface would be:

using System;
using System.Collections.Generic;

namespace Jrpg.CharacterSystem.Scalers.Freelancer
{
    public class AttackScaler : IStatisticScaler
    {
        private StatisticType type = StatisticType.Attack;

        public Statistic Copy(Statistic toCopy)
        {
            var statistic = new Statistic(type, toCopy.MaxValue);
            statistic.CurrentValue = toCopy.CurrentValue;

            return statistic;
        }

        public Statistic NewStatistic(Dictionary<StatisticType, Statistic> statistic)
        {
            var offset = 1;
            var copy = Copy(statistic[type]);

            copy.CurrentValue += offset;

            return copy;
        }
    }
}

Now, our Freelancer class can take advantage of these IStatisticScaler types to calculate all the statistics for the next level with using a simple wrapper method called LevelUp.

With this little manager-type class, our Character instance can store an instance of this class and operate on-demand.

Statistic Labels

Finally, let's give some "nice labels" to our statistics. Previously, I had named the statistics akin to the 8-bit Final Fantasy series, with a "3-letter naming convention". For example, ATK would be Attack, and so on.

FF Stat screen

Let's get away from that and be a little more modern here, and give better names. In CharacterStatistics, we can rename our strings so that we can statistic labels that look more like this:

Better statistics labels

namespace Jrpg.CharacterSystem
{
    public class CharacterStatistics
    {
        public static string LabelHpCurrent = "HP Current";
        public static string LabelHpMax = "HP Max";
        public static string LabelMpCurrent = "MP Current";
        public static string LabelMpMax = "MP Max";
        public static string LabelLevel = "Level";
        public static string LabelExperience = "Experience";
        public static string LabelStrength = "Strength";
        public static string LabelSpeed = "Speed";
        public static string LabelStamina = "Stamina";
        public static string LabelMagic = "Magic";
        public static string LabelAttack = "Attack";
        public static string LabelDefense = "Defense";
        public static string LabelEvasion = "Evasion";
        public static string LabelMagicDefense = "Magic Defense";
        public static string LabelMagicEvasion = "Magic Evasion";
    }
}

Tests

Alright, our basic implementation is here now! Let's write some basic tests to see if:

  1. Our character is created properly.
  2. Our character can level up given a large amount of experience relative to their level.
  3. Our character has a job class associated with it.

The Character is Created

[Fact]
public void CreatesCharacter()
{
  Character hero = new Character("Hero");

  Assert.NotNull(hero);
  Assert.Equal("Hero", hero.Name);
  Assert.Equal(1, hero.Statistics[StatisticType.Level].CurrentValue);
}

Super simple, and straightforward, we just instantiate a Character object, test to see if the character has a name, and assert a basic stat value.

Our Character Can Level Up

[Fact]
public void CharacterLevelsUp()
{
  Character hero = new Character("Hero");
  Assert.Equal(1, hero.Statistics[StatisticType.Level].CurrentValue);
  Assert.Equal(1, hero.Statistics[StatisticType.Strength].CurrentValue);

  var hasLeveledUp = hero.AddExperience(100);

  Assert.True(hasLeveledUp);
  Assert.Equal(7, hero.Statistics[StatisticType.Level].CurrentValue);
  Assert.Equal(7, hero.Statistics[StatisticType.Strength].CurrentValue);

  Assert.Equal(172, hero.ExperienceForNextLevel);
}

The test will give 100 experience to the character, which the expectation is to level them up several levels up to level 7.

Our Character has a Default "Class"

[Fact]
public void CharacterHasDefaultClass()
{
  Character hero = new Character("Hero");
  Assert.Equal("Freelancer", hero.CurrentClassName());
}

We expect that the character is a Freelancer in this test.

We will definitely expand on these set of tests as our character system grows more complex. But for now, these tests, although simple, should give us some confidence that our design works so far.

We're not totally done yet! We changed so much in jrpg-character-system that a simple reference to the library in jrpg-inventory-system is surely going to BREAK it.

The final task is to modify jrpg-inventory-system to use our new naming conventions in jrpg-character-system and additionally fix the existing unit tests in there.

I won't discuss in depth on doing that in this post as I feel like it is a fun exercise to do if you've been following along and coding with me. Or, you can just take a look at the repository. 😄

Conclusion

We've a basic character. Our character has a name, body, and statistics. These fulfill the basic personality, and competency requirements. Our character also can achieve more experience over time. This allows the character potential to get better at something.

Before we go building out skills,s pells and job classes for the character, I'd like to actually take a step to the side and talk about status effects on the character.

But, that's more next time. 😎