D20 RPG – Dice Roll

What better place to begin than with a roll of the dice?

Getting Started

Download the starter project here. Unzip the project and open it with Unity 2021.3.8f1. Inside the project you will find a few simple things to get us started. At the root are the Assets and Packages folders.

The Assets folder contains:

  • A Scripts folder where we will primarily be working from.
  • A Tests folder where we can add unit tests for our scripts.
  • The License file, copied from here, in case you were curious.

The Scripts folder has two folders. The Dependency folder contains the script for our Interface Injection pattern. Read my post Easy Access Architecture to learn more about that. The DiceRoll folder is empty and will hold the scripts we will create in this lesson. The Tests folder also has an empty DiceRoll folder for unit tests of our scripts.

Starter Project

Dice Roll Mechanic

In the Pathfinder SRD Core Rulebook, Getting Started Section, one may read that both player and non-player actions in the game have a randomness to them. The random result is related to the roll of a dice. You can control the difficulty of an action by determining a minimum roll requirement necessary for success.

Throughout the document you will see a sort of “recipe” for a dice roll. Simple dice rolls could be represented as “d#” where the ‘d’ stands for a dice and the ‘#’ stands for the number of sides on the dice. So if you see “d20” it means to roll a twenty sided dice.

You can also see a number appear before the ‘d’ in the recipe. In that case, it indicates a count of the dice to roll. So if you see “4d6” it would mean to roll four six-sided dice.

Finally you might see a ‘+’ or ‘-‘ and a number at the end. It indicates an additional bonus to add or penalty to subtract from the final total result. So if you see 2d10+4, you would roll two ten-sided dice then add four.

Dice Roll Model

Let’s begin to implement the above mechanic. We will start by creating a model to represent the dice roll recipe. Create a new C# script inside the Assets/Scripts/DiceRoll folder and name the script DiceRoll. Copy the following code into the script:

Create -> C# Script

using System;

[Serializable]
public struct DiceRoll
{
    public int count;
    public int sides;
    public int bonus;
}

This is just a simple data structure that holds fields representing the various aspects of a dice roll. I can now easily represent a number of dice to roll, the number of sides on the dice, and a bonus to add at the end. I also added the Serializable attribute so we can easily save the structure or display it in an inspector.

There were various forms of the dice roll recipe such as: d20, 4d6, and 2d10+4. Add the following constructors so that we can create dice rolls without specifying anything beyond what we need:

public DiceRoll(int sides)
{
    this.count = 1;
    this.sides = sides;
    this.bonus = 0;
}

This form would work for the “d20” style recipe. The constructor accepts a single parameter, the number of sides on a dice. In this scenario, we assume that the number of dice to roll will be one, and that the bonus to add will be zero.

public DiceRoll(int count, int sides)
{
    this.count = count;
    this.sides = sides;
    this.bonus = 0;
}

This form would work for the “4d6” style recipe. Here we pass parameters to explicitly define the count of dice and the number of sides of the dice. The bonus is left at its default of zero.

public DiceRoll(int count, int sides, int bonus)
{
    this.count = count;
    this.sides = sides;
    this.bonus = bonus;
}

The third and final form handles the “2d10+4” style recipe. Here we have added a constructor where every field is explicitly provided.

In addition to the constructors, you may find it convenient to define a few of the more commonly used DiceRolls. Add the following examples:

public static readonly DiceRoll D6 = new DiceRoll(6);
public static readonly DiceRoll D20 = new DiceRoll(20);

Here we have provided a preconfigured dice roll for rolling both a six-sided dice and a twenty-sided dice.

Dice Roll System

Now we need to take our model and do something with it. We need to actually “roll” the dice, add the bonus, and obtain a final result. Create a new C# script inside the Assets/Scripts/DiceRoll folder and name the script DiceRollSystem. Copy the following code into the script:

using UnityEngine;

public class DiceRollSystem
{
    public int Roll(DiceRoll diceRoll)
    {
        int result = diceRoll.bonus;
        for (int i = 0; i < diceRoll.count; i++)
            result += Random.Range(1, diceRoll.sides + 1);
        return result;
    }
}

I have added a single method named Roll which accepts a DiceRoll model as a parameter and returns an int value, which is the result of performing the action. It begins at the value of the dice roll’s bonus value, then uses a loop up to the count of dice, to add the value of a roll of a dice. The number of sides of the dice is used to help determine the range of the random number that is generated. When the loop completes, the final result is returned.

Checkpoint

Let’s make a quick test (not to be confused with a unit-test) to make sure our code is working as expected. Create a new C# script inside the Assets/Scripts folder and name the script Checkpoint. Copy the following code into the script:

using UnityEngine;

public class Checkpoint : MonoBehaviour
{
    [SerializeField] DiceRoll diceRoll = DiceRoll.D6;
    DiceRollSystem system = new DiceRollSystem();

    void Update()
    {
        if (Input.GetKeyUp(KeyCode.Return))
        {
            var result = system.Roll(diceRoll);
            Debug.Log(result);
        }    
    }
}

Attach the script to the scene’s Main Camera. (Just the “Untitled” default scene is fine – there is no need to save this part of the work). Press Play and then hit the Return key a few times. You will see the result of performing the DiceRoll printed in the console. You should see values from 1 through 6.

Checkpoint

Check out our script in the Inspector. You should see the ability to set values for each of the fields on the DiceRoll. Play around with different counts of dice, the number of sides on the dice, and the bonus, to see how it will modify the results you see. If everything looks correct, then we can continue.

DiceRoll Inspector

Don’t save the scene (unless you really want to) and feel free to delete the Checkpoint script as well.

Making Our Code Testable

The above checkpoint was a simple way to verify that our code was performing as we expected, but it doesn’t fulfill the goal I have of making the code “testable”. In practice, that means I need to be able to define an interface and work based on that abstract level. Basically, that means that my other code can know about the interface, but not the concrete implementation of the interface. Open up the DiceRollSystem script and add the following:

public interface IDiceRollSystem : IDependency<IDiceRollSystem>
{
    int Roll(DiceRoll diceRoll);
}

Interfaces usually have a naming convention where they are prefixed with an “I”, so I named mine “IDiceRollSystem”. It has the same method signature as the one we had already implemented in the actual system class. In addition, this interface inherits the generic IDependency interface so that we can utilize the interface injection pattern. In the future, I will use an injector to Register the concrete version of the system for when I want to run the game, or to Register a mock system for when I need to run a unit test.

Next, you need to update the class definition for DiceRollSystem so that it implements the new interface:

public class DiceRollSystem : IDiceRollSystem

Even though I prefer my models to be “dumb” (meaning no logic apart from maybe data validation), I tend to prefer the way that object oriented code feels. In other words, I would prefer to just call a Roll method right on the DiceRoll object because it would be shorter to write.

Something that I feel kind of has the best of both worlds, is to create a partial class or struct, where the data is separate from the logic, and even the logic is really just a wrapper for the system. Add the following beneath the system:

public partial struct DiceRoll
{
    public int Roll()
    {
        return IDiceRollSystem.Resolve().Roll(this);
    }
}

This code snippet defines “part” of the implementation of a DiceRoll. It defines a method named Roll that wraps whatever system has been injected into the interface.

Note that when using a partial like this, that all of the definitions must include the partial keyword. You will need to open the DiceRoll model script and update it to match.

public partial struct DiceRoll

Hidden Dependencies

Our code is looking a lot closer to my goal, but it is still not quite there. At the moment we can not create a proper Unit Test for our DiceRollSystem, because it has a hidden dependency. A unit test is supposed to be deterministic, which simply means that I should be able to run a test repeatedly and get the exact same result every time. That is almost the opposite of my system, where the whole point is to generate randomness.

The argument could be made that we can in fact write a deterministic unit test already, by simply setting Unity’s Random.seed, however, this is not quite enough control for me. As one example, suppose I am writing a system that determines whether or not an attack is critical. It would be tedious to check starting seeds to get the random number generator to provide the output I wish to receive. Likewise, if I am writing a unit test that generates multiple rolls, I might want more fine grained control of the sequence of rolls that are made.

Create a new C# script inside the Assets/Scripts/DiceRoll folder and name the script RandomNumberGenerator. Copy the following code into the script:

using UnityEngine;

public interface IRandomNumberGenerator : IDependency<IRandomNumberGenerator>
{
    public int Range(int minInclusive, int maxExclusive);
}

public struct RandomNumberGenerator : IRandomNumberGenerator
{
    public int Range(int minInclusive, int maxExclusive)
    {
        return Random.Range(minInclusive, maxExclusive);
    }
}

Here I have created an interface with a Range method matching the signature that Unity had provided. Then I created a concrete implementation that wraps Unity’s method. Now in any test that relies on a randomly generated number, I can Register a mock with any way I wish to provide the result I want to obtain in my unit test.

Head back to the DiceRollSystem script and modify the line that generates the number to look like this:

result += IRandomNumberGenerator.Resolve().Range(1, diceRoll.sides + 1);

Mock Random Number Generator

Let’s create a mock for our random number generator. Create a new C# script inside the Assets/Tests/DiceRoll folder and name the script MockFixedRNG. Copy the following code into the script:

public class MockFixedRNG : IRandomNumberGenerator
{
    public int fakeOutput;

    public MockFixedRNG(int output)
    {
        fakeOutput = output;
    }

    public int Range(int minInclusive, int maxExclusive)
    {
        return fakeOutput;
    }
}

It is a very simple class that simply conforms to the IRandomNumberGenerator interface and always returns a fixed result that was explicitly provided to it.

Unit Test

Finally everything is testable. Create a new C# test script inside the Assets/Tests/DiceRoll folder and name the script DiceRollSystemTests. Copy the following code into the script:

Create Test Script

using NUnit.Framework;

public class DiceRollSystemTests
{
    [SetUp]
    public void SetUp()
    {
        IRandomNumberGenerator.Register(new MockFixedRNG(7));
    }

    [TearDown]
    public void TearDown()
    {
        IRandomNumberGenerator.Reset();
    }

    [Test]
    public void Roll_Passes()
    {
        var sut = new DiceRollSystem();
        var diceRoll = new DiceRoll(2, 10, 4);
        Assert.AreEqual(18, sut.Roll(diceRoll));
    }
}

Here we use the SetUp method to Register a mock for our IRandomNumberGenerator interface. The mock will always return the fixed value of ‘7’ for each roll of the dice. Since I roll two dice and add ‘4’, the expected total result is ’18’ (7+7+4 = 18). I also use TearDown to clear the registration when my test is done. The test itself is very simple, it creates the sut (subject under test) as well as a sample DiceRoll model, then uses the system to roll the dice roll and match it against the expected result.

In Unity, open the Test Runner (from the menu bar choose Window > General > Test Runner) and then click Run All. The test should pass. Great job!

Test Runner

Summary

In this lesson we created probably the most important of all mechanics in a dice-based game – the ability to roll a dice! I also demonstrated the use of the Interface Injection pattern to help remove hidden dependencies – in this case Unity’s Random Number Generator. Once everything was correctly configured, and with a mock in place, we created our first unit test.

If you got stuck anywhere along the way, check out the end project here.

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

2 thoughts on “D20 RPG – Dice Roll

  1. I’m excited for this one, I’m a huge fan of TTRPGs. I just wanted to point out it might be better to store the results in an array or list rather than just the result. Many mechanics in the game pay attention to the actual number that was rolled on the dice aside from the total result such as critical hits or confirming critical hits and critical failures.

    1. Awesome, thanks. As a heads up, I don’t actually have much experience with these games so it’s possible I will miss or misunderstand some of the mechanics, so keep pointing out stuff as we go. In this case, I do have a side prototype that goes through attacking and includes critical hits etc, so we’ll see if you like my approach. I was thinking of each DiceRoll as a single “roll”, regardless of how many dice are included. So an attack check may just be a single D20, followed up by another roll for a critical check if applicable, but a damage roll may allow a combination of dice and bonus.

Leave a Reply

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