D20 RPG – Ancestries

“To forget one’s ancestors is to be a brook without a source, a tree without a root.” – Chinese Proverb

Overview

Building a character in Pathfinder is a multi-step process involving the assignment of Ancestry, Background, and Class etc. We will be looking at the first of those steps in this lesson, so if you are not already familiar, it may be a good idea to review ancestries here.

When I have a large set of content, like lists of monsters, skills, or in this case, ancestries, I prefer that the “source of truth” exist outside of Unity. Some choices I have used in the past include things like spreadsheets or even json data. Spreadsheets are great for the quick viewing and editing of large quantities of data, whereas other structures like “Json” can represent more complicated relationships.

By keeping the content external to Unity, I have found my projects to be more resilient. To understand why, imagine a scenario where we don’t use this suggestion, and instead build all of our content directly inside of Unity. We are going to build an Ancestry as a project asset. This includes things like an ancestry name, traits, attribute bonuses and more. So you go about it by creating prefabs or scriptable objects with a single script that includes properties for each of these details. There are only 36 ancestries, so while it is a fair amount of content, it isn’t impossible to put in. Things seem to be going well, but then you go to add another feature such as a character’s background and realize that they have overlapping features. Now you pause and ask yourself an important question. Should I refactor my project so that both Ancestries and Backgrounds can use the same logic to apply their ability boosts? If you’re like me, then the answer is probably yes, but you would realize that when adding a second script, that the attribute boost information from the first script won’t magically migrate to the second script. You would have to input it all again, or write additional code to do it for you.

There are other problem scenarios too. Imagine that you have created an enum that is serialized as one of your fields, such as a “Rarity” enum. Later on you decide that you want to insert some cases or re-order them. Uh-oh, Unity has serialized them as their base value (an integer), not as their English readable name in code, so now all of your assets have wrong values!

If you keep your content external to Unity, then that means we generate our content by creating a script that can convert our content into assets. If at any time we decide we wanted assets implemented differently, we just modify the script that handles the conversion and run the conversion again. All of our assets are magically updated to the new structure with a minimal amount of effort. Note that this means you should try to avoid directly editing the generated assets, because a subsequent generation would cause you to lose any such changes. The DOCUMENT is the source of truth and is the location where changes should be made.

Getting Started

Feel free to continue from where we left off, or download and use this project here. It has everything we did in the previous lesson ready to go.

You will also need to download this package, which contains a json file representing the various ancestries.

Let’s go ahead and examine the newly imported file’s contents, here is a snippet representing the overall structure:

{
    "datas": [
        {
            "name": "Dwarf",
            "description": "Dwarves have a well-earned reputation as a stoic and stern people, ensconced within citadels and cities carved from solid rock. While some see them as dour and humorless crafters of stone and metal, dwarves and those who have spent time among them understand their unbridled zeal for their work, caring far more about quality than quantity. To a stranger, they can seem untrusting and clannish, but to their friends and family, they are warm and caring, their halls filled with the sounds of laughter and hammers hitting anvils.",
            "rarity": "Common",
            "traits": "Dwarf, Humanoid",
            "backgrounds": "Acolyte, Artisan, Merchant, Miner, Warrior",
            "classes": "Barbarian, Fighter, Monk, Ranger, Cleric, Druid",
            "names": "Agna, Bodill, Dolgrin, Edrukk, Grunyar, Ingra, Kazmuk, Kotri, Lupp, Morgrym, Rogar, Rusilka, Torra, Yangrit",
            "hitPoints": 10,
            "size": "Medium",
            "speed": 20,
            "boosts": "Constitution, Wisdom, Free",
            "flaws": "Charisma",
            "languages": "Common, Dwarven",
            "extraLanguages": "Gnomish, Goblin, Jotun, Orcish, Terran, Undercommon",
            "vision": "Darkvision",
            "special": "Clan Dagger"
        },
        ... Other ancestries listed here ...
    ]
}

The root element is an object. It has an array value for the key named “datas” which hold another object per ancestry. Note that a json structure can be implemented such that the root element is an array, and that would have been my preferred implementation. However, I made this slight change so that we could more easily use one of Unity’s utilities that expects the root to be an object.

Looking through the fields of the ancestry object you will find many bits of data which our project has already implemented, including:

  • Name
  • Health (hit points)
  • Size
  • Speed
  • Ability Scores

There are many other features which we have not yet implemented including:

  • Backgrounds
  • Classes
  • Traits
  • Languages
  • Vision types (like dark vision)
  • Special features like “Clan Dagger”

To help keep the scope of this lesson down, we will only parse and convert already supported features. As a bonus of this approach, we will reinforce the idea that we can always change our converter later and re-run it to update the generated assets.

Rarity

Feel free to read more about the rarity game mechanic here.

One of the first things I noticed when looking at Ancestries was that they were grouped by rarity – some were Common, others were Uncommon, and there were even Rare ancestries to consider. I debated on how to save this data, and at first created separate data files for the different rarity types. Ultimately I decided to stick them all in a single file, and make sure to include the rarity as a field of each.

Create a new C# script at Scripts -> Component named RaritySystem and add the following:

public enum Rarity
{
    Common,
    Uncommon,
    Rare,
    Unique
}

public partial class Data
{
    public CoreDictionary<Entity, Rarity> rarity = new CoreDictionary<Entity, Rarity>();
}

public interface IRaritySystem : IDependency<IRaritySystem>, IEntityTableSystem<Rarity>
{

}

public class RaritySystem : EntityTableSystem<Rarity>, IRaritySystem
{
    public override CoreDictionary<Entity, Rarity> Table => IDataSystem.Resolve().Data.rarity;
}

public partial struct Entity
{
    public Rarity Rarity
    {
        get { return IRaritySystem.Resolve().Get(this); }
        set { IRaritySystem.Resolve().Set(this, value); }
    }
}

Nothing unusual here, just another entity table system based on a new enum. I won’t actually be using the system portion in this lesson, as the rarity will be saved as a field on an ancestry asset, but I can imagine needing it in the future.

Even though we won’t use this system yet, let’s go ahead and register it in our ComponentInjector so we don’t forget that step if we decide to use it later:

IRaritySystem.Register(new RaritySystem());

Ancestry

Create a new script at Scripts -> Component named Ancestry and add the following:

using UnityEngine;
using System.Collections.Generic;

public interface IAncestry
{
    string Title { get; }
    string Description { get; }
    Rarity Rarity { get; }
    List<IAttributeProvider> AttributeProviders { get; }
}

public class Ancestry : MonoBehaviour, IAncestry
{
    public string Title
    {
        get { return _title; }
    }
    [SerializeField] string _title;

    public string Description
    {
        get { return _description; }
    }
    [SerializeField] string _description;

    public Rarity Rarity
    {
        get { return _rarity; }
    }
    [SerializeField] Rarity _rarity;

    public List<IAttributeProvider> AttributeProviders
    {
        get
        {
            return new List<IAttributeProvider>(gameObject.GetComponents<IAttributeProvider>());
        }
    }

    public void Setup(string title, string description, Rarity rarity)
    {
        _title = title;
        _description = description;
        _rarity = rarity;
    }
}

I started with an interface that defines an ancestry, so our architecture won’t become coupled with any particular implementation. It has a “Title”, which is really just the “Name” of the Ancestry such as “Dwarf” or “Elf”, but I used “Title” to avoid a conflict with the “Name” of a Monobehaviour. It has a “Description” which would be general information about the Ancestry that could be displayed in a UI to help a player determine if they wanted to play as that kind of character. Finally it has a “Rarity” which could be used to determine the likelihood of picking a particular type when auto generating characters in the game, or to control which options to present to a user. Finally it also has a List of IAttributeProvider so that when we use this Ancestry to generate a character, we can also use this List to setup any relevant initial state of our Entity.

The current implementation of the Ancestry is as a subclass of Monobehaviour. This is because I intend to add this script to GameObjects and save them as prefabs (project assets) that can be loaded and referenced as needed. For example, I don’t need duplicate data per Entity of the “Description” of what a “Dwarf” is – it should only exist in once place and be loaded as needed.

Ancestry System

Whenever we want an Entity to be associated with a particular Ancestry, we will want to route that through a system. The system itself can help manage things like whether the data is persisted, and if so, how it is saved. In this case, I will just use an EntityTableSystem based on a string. We will associate the “Title” of an Ancestry with an Entity, and from there should be able to “Load” project assets that have all the extra data which would be the same for any Entity.

Create a new script at Scripts -> Component named AncestrySystem and add the following:

public partial class Data
{
    public CoreDictionary<Entity, string> ancestry = new CoreDictionary<Entity, string>();
}

public interface IAncestrySystem : IDependency<IAncestrySystem>, IEntityTableSystem<string>
{

}

public class AncestrySystem : EntityTableSystem<string>, IAncestrySystem
{
    public override CoreDictionary<Entity, string> Table => IDataSystem.Resolve().Data.ancestry;
}

public partial struct Entity
{
    public string Ancestry
    {
        get { return IAncestrySystem.Resolve().Get(this); }
        set { IAncestrySystem.Resolve().Set(this, value); }
    }
}

To use this system, we will need to register it in our ComponentInjector:

IAncestrySystem.Register(new AncestrySystem());

Ancestry Asset System

Next we will provide the system which knows how to provide Ancestry assets to any code that needs it. Create a new C# script at Scripts -> AssetManager named AncestryAssetSystem and add the following:

using UnityEngine;
using Cysharp.Threading.Tasks;

public interface IAncestryAssetSystem : IDependency<IAncestryAssetSystem>
{
    UniTask<IAncestry> Load(string name);
}

public class AncestryAssetSystem : IAncestryAssetSystem
{
    public async UniTask<IAncestry> Load(string name)
    {
        var assetManager = IAssetManager<GameObject>.Resolve();
        var key = string.Format("Assets/AutoGeneration/Ancestries/{0}.prefab", name);
        var prefab = await assetManager.LoadAssetAsync(key);
        return prefab.GetComponent<IAncestry>();
    }
}

This system follows the same pattern we have used for loading other assets like our Encounter. It wraps the GameObject Asset Manager and provides needed information such as the path to where the asset will be stored. This way, any consumer of the system merely needs to provide the name (“Title”) of the Ancestry that they want to load.

Note that I specified a different folder from our other prefabs and assets because I want to treat auto-generated assets differently from assets that are generated manually. When I use source control like “git”, I may even go as far as ignoring this directory to help reinforce the idea that any manual changes will not be saved and are subject to be overwritten by automated processes.

# Git ignore auto-generated assets
/Assets/AutoGeneration/

To use this system, we will need to register it in our AssetManagerInjector:

IAncestryAssetSystem.Register(new AncestryAssetSystem());

Random Name Provider

While I have had a Name System in place for a long time now, I still have never actually provided an attribute provider to assign a name. It’s about time we fix that. Create a new C# script at Scripts -> AttributeProvider named RandomNameProvider and add the following:

using System.Collections.Generic;
using UnityEngine;

public class RandomNameProvider : MonoBehaviour, IAttributeProvider
{
    public List<string> names;

    public void Setup(Entity entity)
    {
        // TODO: If names is empty, pick from a common list
        if (names == null || names.Count == 0)
        {
            Debug.LogError("No names assigned");
            return;
        }

        var rnd = IRandomNumberGenerator.Resolve().Range(0, names.Count);
        entity.Name = names[rnd];
    }
}

Note that there is no fallback handling yet for cases where the “names” are not actually provided. In such a case I would like to just use a generic list of names, but I haven’t created that list yet. For now there is a placeholder “TODO” comment, a quick Debug.LogError, and also return early so that I don’t crash with an out of bounds error. Only one of the Ancestry types did not provide names, and that was for the Human. You could always add some simple names to the JSON, or create your own fallback list as a quick solution.

Ability Boost Provider

Even though I already have AttributeProviders that can specify AbilityScores, the “Ancestry” should be considered a modifier rather than an initializer when it comes to these stats. In addition, there is a new concept of a “Free” choice that needs to be implemented – that means we can boost any of the score types. Create a new C# script at Scripts -> AttributeProvider named AbilityBoostProvider and add the following:

using System;
using System.Collections.Generic;
using UnityEngine;

public enum AbilityBoost
{
    Strength,
    Dexterity,
    Constitution,
    Intelligence,
    Wisdom,
    Charisma,
    Free
}

public class AbilityBoostProvider : MonoBehaviour, IAttributeProvider
{
    public bool isFlaw;
    public List<AbilityBoost> boosts;

    public void Setup(Entity entity)
    {
        foreach (var boost in boosts)
        {
            AbilityScore.Attribute attribute;
            if (boost == AbilityBoost.Free)
            {
                var options = new List<AbilityBoost>((AbilityBoost[])Enum.GetValues(typeof(AbilityBoost)));
                foreach (var option in boosts)
                    options.Remove(option);
                var rand = UnityEngine.Random.Range(0, options.Count);
                var selectedBoost = options[rand];
                attribute = (AbilityScore.Attribute)selectedBoost;
            }
            else
            {
                attribute = (AbilityScore.Attribute)boost;
            }
            entity[attribute] += isFlaw ? -2 : 2;
        }
    }
}

This script is slightly more complex because I first create an enum to represent the “Boost” options. It is almost the same as the “AbilityScore.Attribute” except that it includes “Free”. The provider has a flag indicating whether a given boost is a bonus or a flaw, along with a List of the boosts to apply. When it goes through the Setup process, I first determine which attribute to actually modify. If the boost was specified, then I can simply cast it as an Attribute because I defined them in the same order. For “Free” boosts, I first create a List of all boost types, then remove whatever boost types were included in the list of boosts – the reason is that a “Free” boost should be for any OTHER type that isn’t already mentioned. Finally, I either add or subtract two points to the attribute score depending on whether we should flag the boost as a flaw or not.

Health, Size, and Speed Providers

For the HealthProvider, SizeProvider, and SpeedProvider, we can use them as-is with the new parser with one small exception. Initially I created them using [SerializeField] because all of the setup was done manually. Now that we are configuring these components by another script, we should change the fields to be public:

// Replace this
[SerializeField] int value;

// With this
public int value;

Modify all three scripts so that their “value” is public.

Ancestry Parser

Create a new folder inside Scripts named PreProduction. Then create a new script inside that folder named AncestryParser and add the following:

using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using System;

public static class AncestryParser
{
    [System.Serializable]
    class AncestryData
    {
        public string name;
        public string description;
        public string rarity;
        public string traits;
        public string backgrounds;
        public string classes;
        public string names;
        public int hitPoints;
        public string size;
        public int speed;
        public int waterSpeed;
        public string boosts;
        public string flaws;
        public string languages;
        public string extraLanguages;
        public string vision;
        public string special;
    }

    [System.Serializable]
    class AncestryDataList
    {
        public List<AncestryData> datas;
    }
}

The parser itself is a static class, which means you don’t create instances of it. You simply use it as a utility when needed. Within the class are two private classes that will help to deserialize the json content. The “AncestryDataList” is a sort of container of a list of all the ancestry data. It represents the root level of the json file which also had a “datas” key. Next we have “AncestryData” which holds fields for each of the keys of any given Ancestry within the array. We won’t be using things like “backgrounds” and “classes” yet, but you can still verify that the data loads properly for when we do need it in the future. Both of these classes are marked as [System.Serializable], so that Unity’s JsonUtility can handle them.

Add the following snippets to the AncestryParser class:

[MenuItem("Pre Production/Generate/Ancestries")]
public static void GenerateAll()
{
    if (!AssetDatabase.IsValidFolder("Assets/AutoGeneration"))
        AssetDatabase.CreateFolder("Assets", "AutoGeneration");
    if (!AssetDatabase.IsValidFolder("Assets/AutoGeneration/Ancestries"))
        AssetDatabase.CreateFolder("Assets/AutoGeneration", "Ancestries");

    string filePath = "Assets/Docs/Ancestries.json";
    TextAsset asset = AssetDatabase.LoadAssetAtPath<TextAsset>(filePath);
    var result = JsonUtility.FromJson<AncestryDataList>(asset.text);
    foreach (var data in result.datas)
    {
        GenerateAsset(data);
    }
}

The GenerateAll method first verifies that the necessary folders exist, and if not, it will create them. Next it will load the “json” file, and deserialize it into our new object types. Then it will loop over each of the Ancestry objects in the list and call another method to convert the data into a project asset. The MenuItem attribute allows this method to be triggered by a menu option from within the Unity editor.

static void GenerateAsset(AncestryData data)
{
    var asset = new GameObject(data.name);
    AddAncestry(asset, data);

    var names = CommaSeparatedStrings(data.names);
    asset.AddComponent<RandomNameProvider>().names = names;

    asset.AddComponent<HealthProvider>().value = data.hitPoints;

    var size = (Size)Enum.Parse(typeof(Size), data.size);
    asset.AddComponent<SizeProvider>().value = size;

    asset.AddComponent<SpeedProvider>().value = data.speed;

    AddBoosts(asset, data.boosts, false);
    if (!string.IsNullOrEmpty(data.flaws))
    {
        AddBoosts(asset, data.flaws, true);
    }

    CreatePrefab(asset, data.name);
    GameObject.DestroyImmediate(asset);
}

The GenerateAsset method will be called once per Ancestry listed in the json. It will create a new project asset for each. The asset is created by making a new GameObject, attaching and configuring components, then making a prefab from it. The initial instance can then be destroyed from the scene since all we really needed was the prefab that now lives as a project asset.

static void AddAncestry(GameObject asset, AncestryData data)
{
    var script = asset.AddComponent<Ancestry>();
    var rarity = (Rarity)Enum.Parse(typeof(Rarity), data.rarity);
    script.Setup(data.name, data.description, rarity);
}

The “AddAncestry” method didn’t technically “need” to be a separate method. Because it is implemented as several statements, I sometimes split it out for readability to help prevent single really long methods. All it does is add our new “Ancestry” component and configure it with the provided data.

static void AddBoosts(GameObject asset, string data, bool isFlaw)
{
    var script = asset.AddComponent<AbilityBoostProvider>();

    var boostNames = CommaSeparatedStrings(data);
    var boosts = new List<AbilityBoost>(boostNames.Count);
    foreach (var boostName in boostNames)
    {
        var boost = (AbilityBoost)Enum.Parse(typeof(AbilityBoost), boostName);
        boosts.Add(boost);
    }

    script.boosts = boosts;
    script.isFlaw = isFlaw;
}

The “AddBoosts” method is reused because the logic is basically the same for both attribute boosts and attribute flaws. I pass an “isFlaw” flag to manage the slight difference.

static void CreatePrefab(GameObject asset, string assetName)
{
    string path = string.Format("Assets/AutoGeneration/Ancestries/{0}.prefab", assetName);
    PrefabUtility.SaveAsPrefabAsset(asset, path);
}

The “CreatePrefab” method uses Unity’s PrefabUtility to actually create the project asset based on the GameObject we have been configuring thus far. Note that each asset will be saved with a name that matches the “AncestryData” “name”.

static List<string> CommaSeparatedStrings(string value)
{
    var components = value.Split(',');
    var result = new List<string>(components.Length);
    foreach (var component in components)
    {
        result.Add(component.Trim());
    }
    return result;
}

The “CommaSeparatedStrings” is a utility method that is reused so that I can turn a string with commas in it, into a List of strings that is trimmed just to the content (no spaces before or after each word). This made it easier when building the initial json, that I could just copy and paste lists from the website such as for the suggested names, or classes that are popular for an Ancestry.

Generate Ancestries

Head over to Unity and check out the change to the menu bar. You should now have a “Pre Production” pull-down. Select “Pre Production -> Generate -> Ancestries”.

Now if you look in the folder at Assets -> AutoGeneration -> Ancestries you should find a whole bunch of new project assets. Scroll through them to verify that they have been configured correctly so far.

Although I said you should not modify the auto generated assets, I will now ask you to do exactly that. I know, I know. Select all of the assets, and then check the box next to “Addressable” so that they can be loaded. It should be possible to add this step in code, but this was easier and the lesson is already a bit long.

Create Hero Party Flow

The basic foundations are laid, so the next step is to actually load and apply an Ancestry to an Entity. We will do this inside the CreateHeroPartyFlow, so go ahead and open that script next. Add the following field:

string[] autoChosenAncestries = new string[] {
    "Dwarf",
    "Elf",
    "Gnome",
    "Halfling"
};

We could have randomly chosen from all ancestries, but this is a simple way to make sure that we don’t have duplicates, and that we avoid the “Human” ancestry which lacks some prefilled data such as names.

In the Play method, just after assigning the entity’s PartyOrder, add the following:

await LoadAncestry(entity, autoChosenAncestries[i]);

Add the method:

async UniTask LoadAncestry(Entity entity, string ancestry)
{
    UnityEngine.Debug.Log(string.Format("Loading Ancestry: {0}", ancestry));
    entity.Ancestry = ancestry;
    var assetSystem = IAncestryAssetSystem.Resolve();
    var ancestryAsset = await assetSystem.Load(ancestry);
    foreach (var provider in ancestryAsset.AttributeProviders)
    {
        provider.Setup(entity);
    }
}

The LoadAncestry method will assign the Ancestry to our Entity, which includes saving the Ancestry name as data. Then it loads the asset of the same name, and handles applying its list of attribute providers to the Entity. A Debug Log was added for sanity sake, because at the moment, there will be no visible changes during the game despite all the work we’ve done.

Hero Asset

For our final step, let’s modify the asset at Objects -> EntityRecipe -> Hero. We will make changes so that it has only the base stats, and then our Ancestry (and in the future a hero’s Background, etc) will be able to modify them as needed.

  1. For the “Ability Scores Provider”, set each of the attributes to ’10’.
  2. Remove the “Health Provider”
  3. Remove the “Speed Provider”
  4. Remove the “Size Provider”

Play the game, and each member of the hero party should now have a unique configuration. Check for the debug logs in the console to verify that ancestries were actually loaded.

Summary

In this lesson we took the first steps of implementing a character’s ancestry. We learned how to use some editor tools to parse a json file and automatically create project assets, then used those assets to help configure our newly created hero party so that each hero would be a little different.

If you got stuck along the way, feel free to download the finished project for this lesson here.

If you find value in my blog, you can support its continued development by becoming my patron. Visit my Patreon page here. Thanks!

Leave a Reply

Your email address will not be published. Required fields are marked *