D20 RPG – Life and Death

“If you live each day as if it was your last, someday you’ll most certainly be right.” – Steve Jobs

Overview

In this lesson, we will handle the concepts of Life and Death. If you aren’t already familiar with the rules, you may wish to start by reading from the SRD here.

In my own words, Hit Points are a number representing the health of a combatant. Every hero and monster will have them, and larger numbers for this stat represent something that can absorb more damage and thus is harder to kill. A paired stat, the “max” hit points, is the health of something at its best – the hit point stat should never exceed the max.

We will modify hit points based on the damage of an attack. If the hit points ever reach zero, the targeted combatant will be knocked out, and is probably dying – well in this lesson at least they will certainly be dying. Non-lethal damage is a feature we can save for another day. Dying is yet another stat that can be applied to an Entity, but for our solo adventure, it will be used to determine when combat ends and the story can resume.

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.

Hit Point System

We will add a hit point stat to our entities the same way we have added other components of data, by implementing it in an entity table system. Create a new folder at Scripts -> Component named Health. Next create a new C# script within the folder named HitPointSystem and add the following:

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

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

}

public class HitPointSystem : EntityTableSystem<int>, IHitPointSystem
{
    public override CoreDictionary<Entity, int> Table => IDataSystem.Resolve().Data.hitPoints;
}

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

Max Hit Point System

The max hit point stat will be handled the same way – as another entity table system. Create a new C# script in the same folder named MaxHitPointSystem and add the following:

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

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

}

public class MaxHitPointSystem : EntityTableSystem<int>, IMaxHitPointSystem
{
    public override CoreDictionary<Entity, int> Table => IDataSystem.Resolve().Data.maxHitPoints;
}

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

Health System

There is a relationship between Hit Points and Max Hit Points, for example that hit points should never exceed the max. In addition, the change of hit points could result in other circumstances, such as the complete loss of hit points resulting in a dying condition. We will create a new system to handle all of these kinds of rules. Add a new C# script to the same folder named HealthSystem and add the following:

using UnityEngine;
using Cysharp.Threading.Tasks;

public struct HealthInfo
{
    public Entity target;
    public int amount;
}

public interface IHealthSystem : IDependency<IHealthSystem>
{
    UniTask Apply(HealthInfo info);
}

We start with a simple interface that holds a single method used to “Apply” some “HealthInfo”. The info defines both a target entity and an amount – this means that we want to modify the “health” (or hit points) of the specified entity by the specified amount. This could be positive or negative.

Add the following:

public class HealthSystem : IHealthSystem
{
    public async UniTask Apply(HealthInfo info)
    {
        if (info.amount > 0)
            await Restore(info);
        else if (info.amount < 0)
            await Damage(info);
        await Present(info);
    }

    async UniTask Restore(HealthInfo info)
    {
        var before = info.target.HitPoints;
        var after = Mathf.Min(before + info.amount, info.target.MaxHitPoints);
        info.target.HitPoints = after;
        if (before == 0)
        {
            await IDyingSystem.Resolve().Revive(info.target);
        }
        await UniTask.CompletedTask;
    }

    async UniTask Damage(HealthInfo info)
    {
        var before = info.target.HitPoints;
        var after = Mathf.Max(before + info.amount, 0);
        info.target.HitPoints = after;
        if (before > 0 && after == 0)
        {
            await IDyingSystem.Resolve().Die(info.target);
        }
        await UniTask.CompletedTask;
    }

    async UniTask Present(HealthInfo info)
    {
        var presentInfo = new HealthPresentationInfo
        {
            target = info.target,
            amount = info.amount
        };
        await IHealthPresenter.Resolve().Present(presentInfo);
    }
}

public partial struct Entity
{
    public float Health
    {
        get {
            var hp = IHitPointSystem.Resolve().Get(this);
            var mhp = IMaxHitPointSystem.Resolve().Get(this);
            return (float)hp / (float)MaxHitPoints;
        }
    }
}

Here we have defined the class that implements our new interface. In the Apply method, the class will first examine the amount of health that should change. Positive changes route through the Restore method, and negative changes route through the Damage method. Regardless of the route, we also will call Present so that any change can be reflected on screen in some way (health bars etc).

The Restore method first makes a copy of the Entity’s hit points “before” making any changes. Then we determine what the new amount of hit points should be “after” our changes. Note that the change isn’t simply adding the “amount” from the info, because we have to make sure that the Entity’s “Max Hit Points” are considered. Furthermore, an increase of health could result in other changes. Shown here, if the Entity’s hit points had been zero, and now are greater than zero, then any “dying” condition should be removed. We will add the “Dying” system later in this lesson.

The Damage also notes the Entity’s hit points “before” we’ve made changes. Then we determine the “after” value for the Entity by adding the negative “amount” of the change, but we also make sure that the hit points do not fall below zero. In the event that this application of a health change is what drops an Entity to zero hit points, then we also will want to add the dying status to the Entity.

The Present method will create a new “info” struct based on the same one used to apply the health change. We want to know the entity that is the target of the change, and the amount of the change. Then we pass the presentation info struct to the resolved health presenter. This is another class we will add later in this lesson.

Health Injector

All three of these new systems will need to be injected, so lets create a new script to handle that. Add a new C# script in the same folder named HealthInjector and add the following:

public static class HealthInjector
{
    public static void Inject()
    {
        IHealthSystem.Register(new HealthSystem());
        IHitPointSystem.Register(new HitPointSystem());
        IMaxHitPointSystem.Register(new MaxHitPointSystem());
    }
}

Next, open the ComponentInjector and add the following to its Inject method:

HealthInjector.Inject();

Health Presenter

We will want to show the changes of health to an Entity in the scene. For this lesson, all that will mean is that we grab the health bar for the targeted entity and adjust its percentage to reflect the amount of hit points remaining. Create a new C# script in the same folder named HealthPresenter and add the following:

using UnityEngine;
using Cysharp.Threading.Tasks;

public struct HealthPresentationInfo
{
    public Entity target;
    public int amount;
}

public interface IHealthPresenter : IDependency<IHealthPresenter>
{
    UniTask Present(HealthPresentationInfo info);
}

public class HealthPresenter : MonoBehaviour, IHealthPresenter
{
    public async UniTask Present(HealthPresentationInfo info)
    {
        var view = IEntityViewProvider.Resolve().GetView(info.target, ViewZone.Combatant);
        var combatantUI = view.GetComponentInChildren<CombatantUI>();
        combatantUI.healthSlider.value = info.target.Health;
        await UniTask.CompletedTask;
    }

    private void OnEnable()
    {
        IHealthPresenter.Register(this);
    }

    private void OnDisable()
    {
        IHealthPresenter.Reset();
    }
}

Health Provider

Now we need to add some attribute providers to our hero and monster so that they actually have a pool of hit points for us to work with. Create a new C# script at Scripts -> AttributeProvider named HealthProvider and add the following:

using UnityEngine;

public class HealthProvider : MonoBehaviour, IAttributeProvider
{
    [SerializeField] int value;

    public void Setup(Entity entity)
    {
        entity.HitPoints = value;
        entity.MaxHitPoints = value;
    }
}

This simple script will apply the same “value” to an Entity’s hit points and max hit point stats at the same time.

Dying System

The SRD actually has a bunch of rules around dying, such that dying is a stat all by itself, and once it reaches a certain level, then “death” is applied. Dying heroes can be stabilized and can have their health restored. Dead heroes must be revived first with high level magic.

For the sake of the “solo” adventure, there is no reason to worry about these additional rules. Since no other heroes are in the party to help, then dying is basically the same as death. Should either the hero or monster drop to zero hit points, then we will have what we need to determine whether the combat has ended in a defeat or victory.

Create a new folder in Scripts -> Component named Dying. Then create a new C# script in the folder named DyingSystem and add the following:

using Cysharp.Threading.Tasks;

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

Dying is a stat much like Hit Points, so we will implement it using the same entity table system pattern. The data for the table, “dying”, maps from an Entity to an int so that in the future we can more easily add those extra rules around the dying condition if we want to.

Add the following:

public interface IDyingSystem : IDependency<IDyingSystem>, IEntityTableSystem<int>
{
    UniTask Die(Entity entity);
    UniTask Revive(Entity entity);
}

In addition to the functionality built into the IEntityTableSystem, I have provided two new methods: Die and Revive. Both return a UniTask, because I want to be able to “present” that kind of action on screen, and the task allows me to wait while that happens.

Add the following:

public class DyingSystem : EntityTableSystem<int>, IDyingSystem
{
    public override CoreDictionary<Entity, int> Table => IDataSystem.Resolve().Data.dying;

    public async UniTask Die(Entity entity)
    {
        Set(entity, 1);
        await PresentDyingState(entity);
    }

    public async UniTask Revive(Entity entity)
    {
        Remove(entity);
        await PresentDyingState(entity);
    }

    async UniTask PresentDyingState(Entity entity)
    {
        var dyingInfo = new DyingPresentationInfo
        {
            entity = entity,
            value = entity.Dying >= 1
        };
        await IDyingPresenter.Resolve().Present(dyingInfo);
    }
}

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

In the implementation for the Die method, I simply use the base class’s Set method to mark an Entity as having the dying condition. Any non-zero value will be treated as dying, and higher values would be closer to death, though we haven’t implemented logic for that yet. After applying the status, we await the presentation of the action.

In the implementation for the Revive method, I use the base class’s Remove method to remove the Entity from the dying table. Once again, I await the presentation of the action.

The PresentDyingState method is used to present dying, whether that means the status has been added, or removed. The presentation info is a bool that is true for any dying value greater than or equal to 1. We call the dying presenter to handle the new information. We will add this class later on in this lesson.

We will need to inject this new system, so open the ComponentInjector and add the following to its Inject method:

IDyingSystem.Register(new DyingSystem());

Dying Presenter

Create a new C# script in the same folder as our dying system, and name it DyingPresenter. Add the following:

using UnityEngine;
using Cysharp.Threading.Tasks;

public struct DyingPresentationInfo
{
    public Entity entity;
    public bool value;
}

public interface IDyingPresenter : IDependency<IDyingPresenter>
{
    UniTask Present(DyingPresentationInfo info);
}

public class DyingPresenter : MonoBehaviour, IDyingPresenter
{
    public async UniTask Present(DyingPresentationInfo info)
    {
        var view = IEntityViewProvider.Resolve().GetView(info.entity, ViewZone.Combatant);
        var combatant = view.GetComponent<CombatantView>();
        var animation = info.value ? CombatantAnimation.Death : CombatantAnimation.Idle;
        ICombatantViewSystem.Resolve().SetAnimation(combatant, animation);
        await UniTask.CompletedTask;
    }

    private void OnEnable()
    {
        IDyingPresenter.Register(this);
    }

    private void OnDisable()
    {
        IDyingPresenter.Reset();
    }
}

The presentation info needs to know what Entity is the focus of the action, and whether it is dying or not. In the future, I may want different info, such as a dying level, and that could be represented in some different way, for example, to help emphasize if a hero is about to die. At the moment, any dying status (or lack of the status) is all we care about. We will be grabbing a combatant’s view, and setting either the “Death” or “Idle” animation state based on whether the Entity is dying or not.

SoloAdventureAttack

Open the SoloAdventureAttack script. So far the script is able to handle things like the attack roll, damage roll, and modifiers that may relate to attributes of the defender, but the final damage is not applied. At the end of the Perform method, delete this:

// TODO: Apply Damage if applicable
var damageAmount = IDamageSystem.Resolve().Apply(damageInfo);
Debug.Log("Final Damage: " + damageAmount);

And then add the following in its place:

// Apply Damage
var damageAmount = IDamageSystem.Resolve().Apply(damageInfo);
var healthInfo = new HealthInfo
{
    target = target,
    amount = -damageAmount
};
await IHealthSystem.Resolve().Apply(healthInfo);

The amount of “damage” done is positive, so when creating the health info struct, we need to remember to negate it – we need to show that we are taking away health by the amount of damage done. Then, we just call the health system, and pass along the required info.

Combat Result System

Now that we have the concept of hit points and a dying status, we have everything we need to determine when a combat has been concluded. Open the CombatResultSystem script and add the following:

using System.Linq;

Then, modify the class’s CheckResult method to the following:

public CombatResult? CheckResult()
{
    var combatants = ICombatantSystem.Resolve().Table;
        
    bool heroAlive = combatants.Any(e => e.Party == Party.Hero && e.HitPoints > 0);
    if (!heroAlive)
        return CombatResult.Defeat;

    bool enemyAlive = combatants.Any(e => e.Party == Party.Monster && e.HitPoints > 0);
    if (!enemyAlive)
        return CombatResult.Victory;

    return null;
}

In this implementation, we grab a reference to the combatant table – a collection of all the combatant entities in the current battle. Then we use Linq to quickly determine if “Any” of the items in the collection match a couple of checks. First we check to see if any combatant in the hero party has hit points. If not, the player has been defeated.

Next, we check to see if any combatant in the monster party has hit points. If not, the player is victorious.

If both the hero and monster party have active battlers, then we can return null to indicate that combat is still ongoing.

Combat Flow

The last thing I want to add is some simple polish. After defeating the monster, it will play its death animation, but the game will then immediately proceed with the story. It is a bit too fast to appreciate what has happened, so I want to add a small delay to give the user a chance to appreciate the sight of their fallen foe. Open the CombatFlow script and add the following:

using System;

Next, change the Exit method to look like this:

async UniTask Exit()
{
    await UniTask.Delay(TimeSpan.FromSeconds(3), ignoreTimeScale: false);
}

This will add a three second pause after combat ends. It could be a great opportunity to play a special music clip, or play additional animations, but that can be left as an exercise to the reader.

Encounter Scene

We added a couple of new presenters in this lesson. Since they subclass MonoBehaviour, they can handle injecting themselves, but we will need to attach them to an object in the scene. Open the Encounter scene, and add both the HealthPresenter and DyingPresenter scripts as components to the “Presenters” game object.

Entity Recipe Assets

Select the Hero entity recipe asset, then attach the HealthProvider with a “value” of 20.

Next, select the Rat entity recipe asset, and attach a HealthProvider with a “value” of 15.

Demo

Go ahead and play the game from the beginning. Battle until combat is concluded, observing changes in health bars, and ending with one combatant fallen to the ground. A short pause later, and the story will resume. Did you win?

Summary

In this lesson we focused on the concept of Health. We added hit points, and limited the stat with max hit points. We also handled a dying status when all hit points were lost. Finally we allowed the game to exit combat based on the death of one of the combatants. The game feels far more complete!

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 *