An int can be anything: points, health, currency, time, etc. We often make mistakes using one int where another int was supposed to go. Imagine a function DoDamage(int, int). It’s not obvious what the parameters mean. Today we’ll use the C# type system to make the code much more readable and less error-prone!

Inspiration

.NET has a great type called TimeSpan. It provides us with a way to specify a time duration without needing to use something like a long that represents a number of ticks. Internally, that’s all a TimeSpan is though. It’s just a struct with one long field.

There are several big advantages to using TimeSpan over just a long. First, it’s obvious when you see TimeSpan that it represents a span of time:

void Fire(Enemy e, long d);     // What is d? Time?
void Fire(Enemy e, TimeSpan d); // d is clearly the duration

Next, TimeSpan removes the need to explitly deal with units of time. There’s no danger that someone will pass milliseconds to Fire when it’s expecting ticks. The reason is that all units of time are handled explicitly by well-named functions:

TimeSpan delay = TimeSpan.FromMilliseconds(100); // Clearly 100ms
double seconds = delay.Seconds;                  // Clearly seconds

Finally, since TimeSpan is a struct it can contain lots of easily-discoverable methods to provide a feature set related to spans of time. This includes the above unit conversions as well as parsing time strings and overloaded operators.

Making Our Own Type

It turns out that it’s really easy for us to create our own strongly-typed integer. Just like how TimeSpan strongly types a long, we can do the same with any other units we might have. Consider a game that includes currency denominated in bronze, silver, and gold. 100 bronze is a silver and 100 silver is a gold. Of course .NET doesn’t provide us a type for this, so we’ll make one ourselves!

First, wrap an integer in a struct named after the type of units:

// Currency whose denominations are gold, silver, and bronze
// 100 bronze to a silver
// 100 silver to a gold
public struct Currency
{
    // Amount of Bronze, which may be over 100
    private uint Amount;
}

Then provide functions to get each denomination. Here’s a Gold property:

public uint Gold
{
    get
    {
        return Amount / 10000;
    }
    set
    {
        uint bronze = Amount;
        uint gold = bronze / 10000;
        bronze -= gold * 10000;
        uint silver = bronze / 100;
        bronze -= silver * 100;
        Amount = value * 10000 + silver * 100 + bronze;
    }
}

On the reverse side, provide factory functions to explicitly create a Currency from a particular denomination:

public static Currency FromGold(uint gold)
{
    return new Currency { Amount = gold * 10000 };
}

Now provide overloaded operators like +:

public static Currency operator +(Currency a, Currency b)
{
    return new Currency { Amount = a.Amount + b.Amount };
}

Finally, implement some common interfaces and fill out functions like Equals and CompareTo:

public struct Currency : IEquatable<Currency>, IComparable<Currency>
{
    public bool Equals(Currency other)
    {
        return Amount == other.Amount;
    }
 
    public int CompareTo(Currency other)
    {
        return Amount.CompareTo(other.Amount);
    }
}

It’s all extremely straightforward, boilerplate work. Here’s a full Currency struct:

using System;
using System.Text;
 
// Currency whose denominations are gold, silver, and bronze
// 100 bronze to a silver
// 100 silver to a gold
public struct Currency : IEquatable<Currency>, IComparable<Currency>
{
    // Amount of Bronze, which may be over 100
    private uint Amount;
 
    public uint Gold
    {
        get
        {
            return Amount / 10000;
        }
        set
        {
            uint bronze = Amount;
            uint gold = bronze / 10000;
            bronze -= gold * 10000;
            uint silver = bronze / 100;
            bronze -= silver * 100;
            Amount = value * 10000 + silver * 100 + bronze;
        }
    }
 
    public uint Silver
    {
        get
        {
            uint bronze = Amount;
            uint gold = bronze / 10000;
            bronze -= gold * 10000;
            return bronze / 100;
        }
        set
        {
            uint bronze = Amount;
            uint gold = bronze / 10000;
            bronze -= gold * 10000;
            uint silver = bronze / 100;
            bronze -= silver * 100;
            Amount = gold * 10000 + value * 100 + bronze;
        }
    }
 
    public uint Bronze
    {
        get
        {
            uint bronze = Amount;
            uint gold = bronze / 10000;
            bronze -= gold * 10000;
            uint silver = bronze / 100;
            bronze -= silver * 100;
            return bronze;
        }
        set
        {
            uint bronze = Amount;
            uint gold = bronze / 10000;
            bronze -= gold * 10000;
            uint silver = bronze / 100;
            Amount = gold * 10000 + silver * 100 + value;
        }
    }
 
    public uint TotalBronze
    {
        get
        {
            return Amount;
        }
    }
 
    public double TotalSilver
    {
        get
        {
            return Amount / 100f;
        }
    }
 
    public double TotalGold
    {
        get
        {
            return Amount / 10000f;
        }
    }
 
    public static Currency FromDenominations(
        uint gold,
        uint silver,
        uint bronze)
    {
        return new Currency { Amount = gold * 10000 + silver * 100 + bronze };
    }
 
    public static Currency FromGold(uint gold)
    {
        return new Currency { Amount = gold * 10000 };
    }
 
    public static Currency FromSilver(uint silver)
    {
        return new Currency { Amount = silver * 100 };
    }
 
    public static Currency FromBronze(uint bronze)
    {
        return new Currency { Amount = bronze };
    }
 
    public static Currency operator ++(Currency currency)
    {
        return new Currency { Amount = currency.Amount + 1 };
    }
 
    public static Currency operator --(Currency currency)
    {
        return new Currency { Amount = currency.Amount - 1 };
    }
 
    public static Currency operator +(Currency a, Currency b)
    {
        return new Currency { Amount = a.Amount + b.Amount };
    }
 
    public static Currency operator -(Currency a, Currency b)
    {
        return new Currency { Amount = a.Amount - b.Amount };
    }
 
    public static Currency operator *(Currency a, uint value)
    {
        return new Currency { Amount = a.Amount * value };
    }
 
    public static Currency operator /(Currency a, uint value)
    {
        return new Currency { Amount = a.Amount / value };
    }
 
    public static bool operator ==(Currency a, Currency b)
    {
        return a.Amount == b.Amount;
    }
 
    public static bool operator !=(Currency a, Currency b)
    {
        return a.Amount != b.Amount;
    }
 
    public static bool operator <(Currency a, Currency b)
    {
        return a.Amount < b.Amount;
    }
 
    public static bool operator >(Currency a, Currency b)
    {
        return a.Amount > b.Amount;
    }
 
    public static bool operator <=(Currency a, Currency b)
    {
        return a.Amount <= b.Amount;
    }
 
    public static bool operator >=(Currency a, Currency b)
    {
        return a.Amount >= b.Amount;
    }
 
    public bool Equals(Currency other)
    {
        return Amount == other.Amount;
    }
 
    public override bool Equals(object obj)
    {
        return obj is Currency other && Equals(other);
    }
 
    public override int GetHashCode()
    {
        return Amount.GetHashCode();
    }
 
    public int CompareTo(Currency other)
    {
        return Amount.CompareTo(other.Amount);
    }
 
    public override string ToString()
    {
        StringBuilder builder = new StringBuilder(16);
        builder.Append('(');
        builder.Append(Gold);
        builder.Append(", ");
        builder.Append(Silver);
        builder.Append(", ");
        builder.Append(Bronze);
        builder.Append(')');
        return builder.ToString();
    }
}
Using Our Own Type

Using Currency is extremely easy and very similar to TimeSpan since it’s API is patterned off of it. Here are some examples that go through each function:

// Default to zero
Currency c = default(Currency);
print(c); // (0, 0, 0)
 
// Get and set denominations
c.Gold = 10;
print(c); // (10, 0, 0)
c.Silver = 20;
print(c); // (10, 20, 0)
c.Bronze = 30;
print(c); // (10, 20, 30)
print(c.Gold);
print(c.Silver);
print(c.Bronze);
 
// Get totals
print(c.TotalBronze); // 102030
print(c.TotalSilver); // 1020.3
print(c.TotalGold); // 10.203
 
// Build from all denominations or just one
c = Currency.FromDenominations(1, 2, 3);
print(c);
c = Currency.FromGold(1);
print(c);
c = Currency.FromSilver(2);
print(c);
c = Currency.FromBronze(3);
print(c);
 
// Reset
c = default(Currency);
print(c);
 
// ++ and --
c++;
print(c); // (0, 0, 1)
++c;
print(c); // (0, 0, 2)
c--;
print(c); // (0, 0, 1)
--c;
print(c); // (0, 0, 0)
 
// += and -=
c += Currency.FromBronze(10);
print(c); // (0, 0, 10)
c -= Currency.FromBronze(4);
print(c); // (0, 0, 6)
 
// + and -
print(c + Currency.FromBronze(2)); // (0, 0, 8)
print(c - Currency.FromBronze(2)); // (0, 0, 4)
 
// * and /
print(c * 2); // (0, 0, 12)
print(c / 2); // (0, 0, 3)
 
// == and !=
print(c == Currency.FromBronze(6)); // true
print(c != Currency.FromBronze(6)); // false
 
// < and >
print(c < Currency.FromBronze(5)); // false
print(c > Currency.FromBronze(5)); // true
 
// <= and >=
print(c <= Currency.FromBronze(5)); // false
print(c >= Currency.FromBronze(6)); // true
 
// Equals(object) and Equals(Currency)
print(c.Equals("something else")); // false
print(c.Equals(Currency.FromBronze(6))); // true
 
// GetHashCode() and CompareTo(Currency)
print(c.GetHashCode()); // 6
print(c.CompareTo(Currency.FromBronze(5))); // 1

We can also use Currency in a Burst-compiled job since it’s just a struct with an int in it. Here’s one that outputs the denominations to a NativeArray<uint>:

[BurstCompile]
struct CurrencyJob : IJob
{
    public Currency Currency;
    public NativeArray<uint> Denominations;
 
    public void Execute()
    {
        Denominations[0] = Currency.Gold;
        Denominations[1] = Currency.Silver;
        Denominations[2] = Currency.Bronze;
    }
}

Here’s how to run the job:

Currency currency = Currency.FromDenominations(1, 2, 3);
NativeArray<uint> denom = new NativeArray<uint>(3, Allocator.TempJob);
new CurrencyJob { Currency = currency, Denominations = denom }.Run();
print(denom[0] + ", " + denom[1] + ", " + denom[2]); // 1, 2, 3
denom.Dispose();

And here’s the assembly it compiles to for 64-bit x86:

mov     eax, dword ptr [rdi]
mov     ecx, 3518437209
imul    rcx, rax
shr     rcx, 45
mov     rdx, qword ptr [rdi + 8]
mov     dword ptr [rdx], ecx
imul    ecx, ecx, 10000
sub     eax, ecx
imul    rcx, rax, 1374389535
shr     rcx, 37
mov     dword ptr [rdx + 4], ecx
imul    ecx, ecx, 100
sub     eax, ecx
mov     dword ptr [rdx + 8], eax
ret

Notice that no overhead has been added for any of this convenience. The resulting code is just like if we’d used a raw uint and written the Gold, Silver, and Bronze properties directly inside the job’s Execute function.

Conclusion

Wrapping integers in a struct has numerous advantages. We make our APIs clearer and in so doing less error-prone as passing the wrong type of unit is now very difficult. We simply can’t pass health points to a function expecting time. We also make it much less likely that we’ll accidentally use the wrong scale of units or incorrectly convert between them. We can’t forget to convert milliseconds to ticks or multiply by the wrong number of zeroes. Other primitives, such as float, can also be wrapped with similar results.

It’s worth thinking through the types of units in your game and considering adding a wrapper struct for them. Investing a few minutes to create it might pay off many times in reduced debugging and improved readability.