D20 RPG – Size, Space and Reach

There are plenty of ways to improve the combat in our project, but the first issue that sticks out to me is that attacks can happen from and to anywhere on the board. In this lesson we will begin to fix this issue by introducing size and reach mechanics.

Overview

To get started, you may want to look at the official rules regarding Size, Space, and Reach. Different creatures can be different sizes, and accordingly may occupy different numbers of tiles, or be able to reach different tiles (such as for attacking). The link shows a handy table showing how a creature’s size relates to its space and reach.

I may or may not have understood the rules correctly in my implementation, but the end result of what I am making is represented in the below image, where I show examples of the space and reach of four different sizes (from medium and larger) – the water tile represents the space the creature of that size would occupy, and the grass tiles represent the reach of the creature.

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.

Size System

Create a new folder at Scripts -> Component named Size. Next, create a new script within that folder named SizeSystem and add the following:

public enum Size
{
    Tiny,
    Small,
    Medium,
    Large,
    Huge,
    Gargantuan
}

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

public interface ISizeSystem : IDependency<ISizeSystem>, IEntityTableSystem<Size>
{
    
}

public class SizeSystem : EntityTableSystem<Size>, ISizeSystem
{
    public override CoreDictionary<Entity, Size> Table => IDataSystem.Resolve().Data.size;
}

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

I start out by adding an enum representing the possible sizes that were found in the Table of sizes at the Archives of Nethys. We can use this enum to represent an Entity’s size in an abstract way. The enum itself is associated with an Entity by a new EntityTableSystem subclass.

Space System

Next, I will create a system that creates a more concrete idea of what a given “size” means. I want to know how size relates to space, both in units of feet (because many mechanics reference this real world unit) and in board tiles (where each board tile is the same as 5 feet). Create a new C# script within the same folder named SpaceSystem and add the following:

using System.Collections.Generic;

public interface ISpaceSystem : IDependency<ISpaceSystem>
{
    int SpaceInFeet(Size size);
    int SpaceInTiles(Size size);
    List<Point> SpaceTileOffsets(Size size);
    List<Point> SpaceTiles(Size size, Point position);
}

public class SpaceSystem : ISpaceSystem
{
    const int tileSize = 5;

    public int SpaceInFeet(Size size)
    {
        switch (size)
        {
            case Size.Large: return 10;
            case Size.Huge: return 15;
            case Size.Gargantuan: return 20;
            default: return 5;
        }
    }

    public int SpaceInTiles(Size size)
    {
        var space = SpaceInFeet(size);
        return space / tileSize;
    }

    public List<Point> SpaceTileOffsets(Size size)
    {
        return SpaceTiles(size, new Point(0, 0));
    }

    public List<Point> SpaceTiles(Size size, Point position)
    {
        var space = SpaceInTiles(size);
        var result = new List<Point>();
        for (int y = 0; y < space; ++y)
        {
            for (int x = 0; x < space; ++x)
            {
                result.Add(new Point(x, y) + position);
            }
        }
        return result;
    }
}

This system is more of a utility class, because it doesn't store any data. At the top of the class I added a "tileSize" const that is set to 5 and represents the number of "feet" a tile on the game board spans. This will help me convert from feet to tiles.

Next, I added a method named SpaceInFeet which converts from a Size to an int which is the amount of feet the size occupies. Note that according to the official rules, a "Tiny" creature occupies "less than 5 feet", but I am not yet certain how I will want to represent this in game. For now, I have just marked any size "Medium" or smaller to be 5 feet so that they can relate to 1 tile.

The method SpaceInTiles takes the value from SpaceInFeet and divides it by the const tileSize. So for example, a Medium sized unit like our hero, which occupies 5 feet of space, will occupy only one tile.

The method SpaceTileOffsets will return a List of Point that you could think of as "local space" tile positions representing the tiles that would be occupied by a creature of the given size. You could loop over those positions, adding them to the actual position of an Entity and then you would have the "world space" tile positions - that is basically what the last method SpaceTiles does - you just specify the position to add.

Reach System

Our next system shows the "Reach" that a given unit should have based on its size (and whether it is a tall or long type of creature). For example, a medium sized creature can "reach" each of the surrounding tiles and could attack an enemy there. A larger creature could reach even further.

Create another C# script in the same folder named ReachSystem and add the following:

using UnityEngine;
using System.Collections.Generic;

public enum Reach
{
    Tall,
    Long
}

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

public interface IReachSystem : IDependency<IReachSystem>, IEntityTableSystem<Reach>
{
    int ReachInFeet(Size size, Reach reach);
    int ReachInTiles(Size size, Reach reach);
    List<Point> ReachTileOffsets(Size size, Reach reach);
    List<Point> ReachTiles(Size size, Reach reach, Point position);
    List<Entity> EntitiesInReach(Entity entity);
}

public class ReachSystem : EntityTableSystem<Reach>, IReachSystem
{
    public override CoreDictionary<Entity, Reach> Table => IDataSystem.Resolve().Data.reach;

    const int tileSize = 5;

    public int ReachInFeet(Size size, Reach reach)
    {
        switch (size)
        {
            case Size.Tiny:
                return 0;
            case Size.Small:
            case Size.Medium:
                return 5;
            case Size.Large:
                return reach == Reach.Tall ? 10 : 5;
            case Size.Huge:
                return reach == Reach.Tall ? 15 : 10;
            default://  case Size.Gargantuan:
                return reach == Reach.Tall ? 20 : 15;
        }
    }

    public int ReachInTiles(Size size, Reach reach)
    {
        var range = ReachInFeet(size, reach);
        return range / tileSize;
    }

    public List<Point> ReachTileOffsets(Size size, Reach reach)
    {
        return ReachTiles(size, reach, new Point(0, 0));
    }

    public List<Point> ReachTiles(Size size, Reach reach, Point position)
    {
        List<Point> result = new List<Point>();
        int space = ISpaceSystem.Resolve().SpaceInTiles(size);
        int range = ReachInTiles(size, reach);
        for (int y = -range; y < space + range; ++y)
        {
            for (int x = -range; x < space + range; ++x)
            {
                int delta = int.MaxValue;
                if (x >= 0 && x < space)
                    delta = y < 0 ? Mathf.Abs(y) : y - space;
                else if (y >= 0 && y < space)
                    delta = x < 0 ? Mathf.Abs(x) : x - space;
                else
                {
                    var dX = x < 0 ? Mathf.Abs(x) : x - space + 1;
                    var dY = y < 0 ? Mathf.Abs(y) : y - space + 1;

                    var max = Mathf.Max(dX, dY);
                    var min = Mathf.Min(dX, dY);

                    delta = max + min / 2;
                }

                if (delta <= range)
                    result.Add(new Point(x, y) + position);
            }
        }
        return result;
    }

    public List<Entity> EntitiesInReach(Entity entity)
    {
        var result = new List<Entity>();

        var sizeSystem = ISizeSystem.Resolve();
        var spaceSystem = ISpaceSystem.Resolve();

        var reachablePositions = new HashSet<Point>(ReachTiles(entity.Size, entity.Reach, entity.Position));

        var candidates = new HashSet<Entity>(sizeSystem.Table.Keys);
        var entitiesWithPosition = new HashSet<Entity>(IPositionSystem.Resolve().Table.Keys);
        candidates.IntersectWith(entitiesWithPosition);

        foreach (var candidate in candidates)
        {
            var candidateSpace = spaceSystem.SpaceTiles(candidate.Size, candidate.Position);
            if (reachablePositions.Overlaps(candidateSpace))
            {
                result.Add(candidate);
            }
        }
        return result;
    }
}

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

This system has both data storage and functionality. The system is built as an EntityTableSystem based on another new enum type called Reach which holds the values Tall and Long. If I understand correctly, a "Tall" creature would be one that stands upright, like a human. A "Long" creature would be something that is more aligned with the ground, like a snake.

The ReachInFeet method is also based on the same Table we used to provide values for the Space system. Like before it returns a value based on the provided Size, but also takes into account the provided Reach - in some cases, the "Tall" creature will reach further than the "Long" one.

The ReachInTiles is likewise used to convert from "feet" to "tiles".

Then we have ReachTileOffsets and ReachTiles which provide a List of Point tile coordinates in "Local" or "Global" space similar to how we provided Tile positions in the SpaceSystem. The code is a bit complex to look at, but what it is doing is simple enough. Basically it loops from a point that is on the negative side of the occupied space of the creature, and extends just as far to the positive side of the occupied space of the creature. It does this in both the X and Y axis, until it has reached the extents of the "Reach" of the creature in each direction. Assuming I implemented it correctly, it would have a range that is basically the same as how the game treats pathfinding.

The final method, EntitiesInReach takes an Entity as a parameter and will return a List of Entity that it could reach from its current position. This is ALL entities, both friend and foe. I Implemented it by first grabbing the set of all entities that have a "Size", and "Intersecting" that set with all Entities that have a "Position". So any entity having both a size and position will be a candidate Entity that could be returned. That may have been an unnecessary step - I could probably have just looped over the list of combatants or something, but I wondered if I may want to consider actions related to non-creatures also - like being able to pick up an item. The check to see whether or not the entity is returned is to see if the "SpaceTiles" of the candidate overlap with the "ReachTiles" of the entity that we are checking the reach of.

Injection

We will need to inject these three new systems, so create another script within the same folder named SizeInjector and add the following:

public static class SizeInjector
{
    public static void Inject()
    {
        IReachSystem.Register(new ReachSystem());
        ISpaceSystem.Register(new SpaceSystem());
        ISizeSystem.Register(new SizeSystem());
    }
}

Open the ComponentInjector from the parent folder and add the following statement to its Inject method:

SizeInjector.Inject();

Size Provider

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

using UnityEngine;

public class SizeProvider : MonoBehaviour, IAttributeProvider
{
    [SerializeField] Size value;

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

This is a simple Attribute Provider that will allow us to assign a "Size" to an Entity via our Object Recipe assets.

Reach Provider

Create another C# script in the same folder named ReachProvider and add the following:

using UnityEngine;

public class ReachProvider : MonoBehaviour, IAttributeProvider
{
    [SerializeField] Reach value;

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

We will use this Attribute Provider in combination with the Size Provider to make sure that our Entity has all of the information necessary to determine how far away from itself that it can reach. Initially this will only play a part for attacking.

Entity Recipe

Let's configure our Entity Recipe assets with the new attribute provider scripts. Navigate to the folder at Asssets -> Objects -> EntityRecipe then select the "Hero" asset. Add the SizeProvider with a value of "Medium". Then add a ReachProvider with a value of "Tall".

Next, select the "Rat" asset. Add the SizeProvider with a value of "Small". Then add a ReachProvider with a value of "Long". Make sure to save changes to both assets by saving the project.

Turn System

Now that we have programmed some mechanics to help the game understand concepts of size, space and reach, we need to make use of it. At the beginning of a turn, we will query for all the entities within reach of current entity. We will just keep this list of entities handy so that our actions can more easily determine whether or not they should be able to perform. For example, if no enemies are within reach, then we shouldn't be able to strike.

Open the script at Scripts -> Combat -> TurnSystem. To the interface, add the following Property definition:

List<Entity> InReach { get; set; }

Note that because of the generic list we also need to add a using statement:

using System.Collections.Generic;

Next, we will add the property to the class:

public List<Entity> InReach { get; set; }

Turn Flow

While the TurnSystem will hold state around the "Turn", the TurnFlow is used to determine when and how that state is populated. Open the script at Assets -> Scripts -> Flow -> TurnFlow.

At the very beginning of the while loop's body, insert the following line:

system.InReach = IReachSystem.Resolve().EntitiesInReach(entity);

ICombatAction

For each of our ICombatAction options, I want to have a quick check for whether or not the action can be performed at a high level. For example, if we know that no opponents are within reach, then I would want the attack action to indicate that it couldn't be used. To handle this, we will add a new method signature to our interface. Go ahead and open the script at Scripts -> Combat -> Actions -> ICombatAction and add the following:

bool CanPerform(Entity entity);

This of course will cause errors in our action scripts indicating where we need to implement this new feature. Open the Stride script first and add the following simple implementation:

public bool CanPerform(Entity entity)
{
    return true;
}

In the future, it may be that mobility is blocked, such as if you are being grappled, and in those cases we would need to return false, but for now, we have no mechanic to block movement so we will just say that you may always use the stride action.

Next we will open the SoloAdventureAttack script and add the following implementation:

public bool CanPerform(Entity entity)
{
    return ITurnSystem.Resolve().InReach.Any(e => e.Party != entity.Party);
}

This is also a simple implementation that merely checks if any of the entities within reach are associated with the opponent party. We will add more strict checks in the future, but for now, this is enough to verify that our size, space and reach mechanics are working.

Monster Action Flow

Since we've only enabled the monsters to do a single action so far, we will have to provide a way to keep combat moving in the event that they can't attack. Open the MonsterActionFlow script. Replace the statement where we "Perform" the selected action with the following:

if (action.CanPerform(current))
    await action.Perform(current);
else
    ITurnSystem.Resolve().TakeAction(3, false);

Now, when the hero moves out of the range of the rat, the rat will just skip its turn.

Demo

At the moment, you will only be able to determine that our new mechanics are working by seeing whether or not the monster will attack on its turn. Try ending a Hero's turn outside the reach of the rat - it will appear like the rat doesn't even get a turn. Then, try ending a turn within reach of the rat - it will attack like normal.

Note that the Hero is still allowed to attack the rat, even from "illegal" locations - that is due to how we implemented the selection mechanic for the attack targets. We will "fix" that in another lesson as we continue to improve combat.

Summary

In this lesson we implemented some new game mechanics related to the size, space and reach of creatures. We used it to restrict the monsters to only be able to attack the hero when the hero is nearby.

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!

4 thoughts on “D20 RPG – Size, Space and Reach

  1. (It might be awkward because it’s a translator. I’m sorry.)
    “Hello! I always enjoy seeing your projects. They’re very inspiring, even for beginners like me. Would it be okay if I asked you some questions about the Unity Tactics RPG you made in the past? Or do you have an email where I can reach you for questions? Thank you!”

Leave a Reply

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