D20 RPG – Perception & Initiative

“I see what you did there.” – source unclear (see what I did there?)

Overview

Another stat that will be assigned by a hero class is perception. In short, this stat controls how observant you are, and plays a role in things like a combat’s initiative order. You can read more about it here.

Initiative is a derived stat, usually from a perception check, that is assigned when combat begins. The higher the Initiative, the sooner you can act. You can read more about this mechanic here.

For a little while now, we have been adding support for mechanics without really applying them to actual game play. In order to keep things a bit more fun, I decided that while adding support for perception that we would also take the opportunity to roll for initiative when combat begins. This step will add some variation to who acts first. Now, one of the monsters might strike before a hero can move!

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.

Perception Proficiency System

We will begin with implementing the system that manages the proficiency level of the perception ability. Create a new folder at Scripts -> Component named Perception. Next create a new script within that folder named PerceptionProficiencySystem and add the following:

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

public interface IPerceptionProficiencySystem : IDependency<IPerceptionProficiencySystem>, IEntityTableSystem<Proficiency>
{

}

public class PerceptionProficiencySystem : EntityTableSystem<Proficiency>, IPerceptionProficiencySystem
{
    public override CoreDictionary<Entity, Proficiency> Table => IDataSystem.Resolve().Data.perceptionProficiency;
}

Pretty basic stuff – we merely created another Entity Table System for another stat.

Perception System

Another script will handle calculating and assigning a “perception” stat value. Like many other stats, this is calculated not only on the “proficiency”, but also on an ability score modifier. In this case it uses Wisdom. Create a new C# script in the same folder named PerceptionSystem and add the following:

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

public interface IPerceptionSystem : IDependency<IPerceptionSystem>, IEntityTableSystem<int>
{
    void Setup(Entity entity);
}

public class PerceptionSystem : EntityTableSystem<int>, IPerceptionSystem
{
    public override CoreDictionary<Entity, int> Table => IDataSystem.Resolve().Data.perception;

    public void Setup(Entity entity)
    {
        Table[entity] = Calculate(entity);
    }

    int Calculate(Entity entity)
    {
        int result = entity[AbilityScore.Attribute.Wisdom].Modifier;
        var proficiency = IPerceptionProficiencySystem.Resolve().Get(entity);
        if (proficiency != Proficiency.Untrained)
            result += (int)proficiency * 2 + entity.Level;
        UnityEngine.Debug.Log(string.Format("Proficiency: {0}", result));
        return result;
    }
}

public partial struct Entity
{
    public int Perception
    {
        get { return IPerceptionSystem.Resolve().Get(this); }
        set { IPerceptionSystem.Resolve().Set(this, value); }
    }
}

Hopefully you will recognize that I am following similar patterns here as I did for other classes like saving throws or skills. There are potentially opportunities to refactor to reduce some code, and encourage you to think in that direction.

Perception Injector

These two new systems will need to be injected, so let’s go ahead and create an injector class to handle it. Create another script in the same folder named PerceptionInjector and add the following:

public class PerceptionInjector
{
    public static void Inject()
    {
        IPerceptionProficiencySystem.Register(new PerceptionProficiencySystem());
        IPerceptionSystem.Register(new PerceptionSystem());
    }
}

We will invoke our new injector from the ComponentInjector, so add the following there:

PerceptionInjector.Inject();

Perception Proficiency Provider

In the near future, proficiency in perception will be configured via a hero class. For the sake of a demo though, we can go ahead and create a provider that we can attach to our Hero recipe asset. Create a new C# script at Scripts -> AttributeProvider named PerceptionProficiencyProvider and add the following:

using UnityEngine;

public class PerceptionProficiencyProvider : MonoBehaviour, IAttributeProvider
{
    public Proficiency proficiency;

    public void Setup(Entity entity)
    {
        IPerformanceProficiencySystem.Resolve().Set(entity, proficiency);
    }
}

Hero Asset

Open the asset at Objects -> EntityRecipe -> Hero and attach our new PerceptionProficiencyProvider to it. Give the hero a Trained level of proficiency. This will help give it a slight advantage in the roll for initiative.

Create Hero Party Flow

Remember that having proficiency in a stat, doesn’t by itself assign a stat value. We still need to trigger the perception system to calculate its final value for each hero. We can do that in the CreateHeroPartyFlow just after doing the setup for saving throws:

IPerceptionSystem.Resolve().Setup(entity);

And with that, we have finished implementing support for the perception stat. Next we can start applying it to game play.

Initiative System

For this lesson we will apply the perception stat by using it to calculate another stat, initiative which will then control the turn order in combat. Create a new script at Scripts -> Component named InitiativeSystem and add the following:

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

public interface IInitiativeSystem : IDependency<IInitiativeSystem>, IEntityTableSystem<int>
{

}

public class InitiativeSystem : EntityTableSystem<int>, IInitiativeSystem
{
    public override CoreDictionary<Entity, int> Table => IDataSystem.Resolve().Data.initiative;
}

public partial struct Entity
{
    public int Initiative
    {
        get { return IInitiativeSystem.Resolve().Get(this); }
        set { IInitiativeSystem.Resolve().Set(this, value); }
    }
}

We can inject this system via the ComponentInjector so make sure to add the following:

IInitiativeSystem.Register(new InitiativeSystem());

Roll Initiative System

Next we will create the system that calculates the initiative for any given entity. Create a new C# script at Scripts -> Combat named RollInitiativeSystem and add the following:

using System.Collections.Generic;

public interface IRollInitiativeSystem : IDependency<IRollInitiativeSystem>
{
    void Roll(Entity entity);
    void Roll(IEnumerable<Entity> entities);
}

public class RollInitiativeSystem : IRollInitiativeSystem
{
    public void Roll(Entity entity)
    {
        entity.Initiative = DiceRoll.D20.Roll() + entity.Perception;
    }

    public void Roll(IEnumerable<Entity> entities)
    {
        foreach (var entity in entities)
            Roll(entity);
    }
}

I start with an interface that has two methods. One to “Roll” the initiative stat value for a single Entity, and another for a collection of entities. In our first pass of the implementation, we will simply roll a D20 and add the result to the entity’s perception stat. This is called a perception check. Remember though that this system could be more complex in the future, such as by choosing to use different kinds of “checks” depending on a variety of circumstances. A rogue hiding around the corner might instead use a “stealth” check.

To use this system, we will inject it via the CombatInjector, so add the following there:

IRollInitiativeSystem.Register(new RollInitiativeSystem());

Combat Flow

The point where we will want to “Roll for Initiative” is right when Combat begins. Therefore, we can open the CombatFlow and change the Enter method to the following:

async UniTask Enter()
{
    // TODO: surprise attacks, etc
    var entities = new List<Entity>(ICombatantSystem.Resolve().Table);
    IRollInitiativeSystem.Resolve().Roll(entities);
    await UniTask.CompletedTask;
}

Note that we will need another using statement due to the generic list:

using System.Collections.Generic;

Round Flow

Now that our combatants all have an associated initiative, we need to use that to control the turn order. The “Round” holds the ordered entities, so we will need to open the RoundFlow script and change the Play method to the following:

public async UniTask<CombatResult?> Play()
{
    var entities = new List<Entity>(ICombatantSystem.Resolve().Table);
    var turnOrder = entities.OrderByDescending(e => e.Initiative).ToList();

    var system = IRoundSystem.Resolve();
    system.Begin(turnOrder);

    CombatResult? result = null;
    while (!system.IsComplete)
    {
        var entity = system.Next();
        result = await ITurnFlow.Resolve().Play(entity);
        if (result.HasValue)
            break;
    }
    return result;
}

Here, we created a turnOrder by using Linq to sort a List of Entity by their initiative. We sorted in descending order, so that the higher initiative stats will be first in the list, and therefore get to take their turn first. Because we are using Linq, we also need another using statement:

using System.Linq;

Demo

Go ahead and play the game like normal (starting from the Loading Screen scene), and when you reach the encounter, note that turns should occur in a different order than they used to. In the past, each of the hero entities would take a turn, and then the monsters. Now, the hero’s will take their turns in a more random order, and the rats may even get to take a turn first!

Summary

In this lesson we added support for perception and initiative. We created a system to “Roll For Initiative” using a perception check. Then we used the initiative order to control the turn order of our encounter. It is a subtle but nice improvement to the feeling of the game.

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 *