We create objects out of structs and classes all the time, but oftentimes these evolve to the point where using them is really awkward. Today we’ll learn to recognize the telltale signs of an overextended object design and how to easily fix it.

It’s a familiar story with object-oriented programming. We start by creating a class or struct type to represent some real-world concept. Say we’re just starting the development of our game and we want an object to represent a human player. We go ahead and create the Player type:

class Player
{
}

The idea here is that the Player class will represent the human player. It’ll do this by containing fields for all the data about the player and functions for all the things it can do. Let’s say our game has combat in it, so we’ll add some fields and functions for that.

class Player
{
    private int m_Health;
    private int m_MaxHealth;
 
    public Player(int health)
    {
        m_Health = health;
        m_MaxHealth = health;
    }
 
    public int Health
    {
        get
        {
             return m_Health;
        }
    }
 
    public void TakeDamage(int amount)
    {
        m_Health = Math.Max(0, Math.Min(m_MaxHealth, m_Health - amount));
    }
}

The Player type is small and manageable at this point. We can use it easily, such as to write a unit test:

[Test]
void TakeDamageIsClampedToZero()
{
    Player player = new Player(10);
    player.TakeDamage(20);
    Assert.That(player.Health, Is.EqualTo(0));
}

As we continue to implement the game, we’ll need to add a lot more functionality than this. So when we need to make the player move, we return to our Player type as the container of all things related to the real-world player and add the fields and functions there:

class Player
{
    private int m_Health;
    private int m_MaxHealth;
    private Vector3 m_Position;
    private Vector3 m_Velocity;
 
    public Player(int health, Vector3 position, Vector3 velocity)
    {
        m_Health = health;
        m_MaxHealth = health;
        m_Position = position;
        m_Velocity = velocity;
    }
 
    public int Health
    {
        get
        {
             return m_Health;
        }
    }
 
    public void TakeDamage(int amount)
    {
        m_Health = Math.Max(0, Math.Min(m_MaxHealth, m_Health - amount));
    }
 
    public Vector3 Position
    {
        get
        {
            return m_Position;
        }
    }
 
    public Vector3 Velocity
    {
        get
        {
            return m_Velocity;
        }
    }
 
    public void Move(float elapsedSeconds)
    {
        m_Position += m_Velocity * elapsedSeconds;
    }
}

By adding position and velocity, we’ve about doubled the size of the Player class. In so doing, we’ve made it harder to use. This isn’t just the case for movement code, but even for combat code like the unit test we wrote before. Because the constructor now takes the position and velocity vectors, we have to go back and update our unit test so it passes those parameters:

[Test]
void TakeDamageIsClampedToZero()
{
    Player player = new Player(10, Vector3.zero, Vector3.zero);
    player.TakeDamage(20);
    Assert.That(player.Health, Is.EqualTo(0));
}

One popular object-oriented approach to solving this problem is to invoke the single responsibility principal and say that Player class shouldn’t have two responsibilities: combat and movement. So we’d go ahead and break it up into three classes:

class PlayerCombat
{
    private int m_Health;
    private int m_MaxHealth;
 
    public PlayerCombat(int health)
    {
        m_Health = health;
        m_MaxHealth = health;
    }
 
    public int Health
    {
        get
        {
             return m_Health;
        }
    }
 
    public void TakeDamage(int amount)
    {
        m_Health = Math.Max(0, Math.Min(m_MaxHealth, m_Health - amount));
    }
}
 
class PlayerMovement
{
    private Vector3 m_Position;
    private Vector3 m_Velocity;
 
    public Player(Vector3 position, Vector3 velocity)
    {
        m_Position = position;
        m_Velocity = velocity;
    }
 
    public Vector3 Position
    {
        get
        {
            return m_Position;
        }
    }
 
    public Vector3 Velocity
    {
        get
        {
            return m_Velocity;
        }
    }
 
    public void Move(float elapsedSeconds)
    {
        m_Position += m_Velocity * elapsedSeconds;
    }
}
 
class Player
{
    private PlayerCombat m_Combat;
    private PlayerMovement m_Movement;
 
    public Player(PlayerCombat combat, PlayerMovement movement)
    {
        m_Combat = combat;
        m_Movement = movement;
    }
 
    public PlayerCombat Combat
    {
        get
        {
             return m_Combat;
        }
    }
 
    public PlayerMovement Movement
    {
        get
        {
            return m_Movement;
        }
    }
}

The new PlayerCombat class looks just like the old Player class. It’s small and focused on just combat. We can use it just like we did before we added movement:

[Test]
void TakeDamageIsClampedToZero()
{
    PlayerCombat combat = new PlayerCombat(10);
    combat.TakeDamage(20);
    Assert.That(combat.Health, Is.EqualTo(0));
}

However, we now have some new problems with the Player class. Our unit test code using it needs to be updated since its interface changed quite a lot:

[Test]
void TakeDamageIsClampedToZero()
{
    PlayerCombat combat = new PlayerCombat(10);
    PlayerMovement movement = new PlayerMovement(Vector3.zero, Vector3.zero);
    Player player = new Player(combat, movement);
    player.Combat.TakeDamage(20);
    Assert.That(player.Combat.Health, Is.EqualTo(0));
}

This code is even more awkward to write than before. The Player class now has dependencies on the PlayerCombat and PlayerMovement classes that must be satisfied by passing them to its constructor. This is still just combat code, but it needs to create a PlayerMovement object just to use the Player for combat. We could pass null instead, but that may lead to other errors such as the Player constructor detecting a null value and throwing an exception.

The other issue is that this unit test now violates another object-oriented principle: the law of demeter. That’s because the unit test doesn’t just “talk” to its “immediate friends” but instead “talks” to “strangers.” Concretely, the unit test gets the PlayerCombat out of the Player via the Combat property and directly calls its methods without going through the Player as an intermediary.

To address this, Player can act as a proxy for the interfaces of the PlayerCombat and PlayerMovement fields it contains:

class Player
{
    private PlayerCombat m_Combat;
    private PlayerMovement m_Movement;
 
    public Player(PlayerCombat combat, PlayerMovement movement)
    {
        m_Combat = combat;
        m_Movement = movement;
    }
 
    public int Health
    {
        get
        {
             return m_Combat.m_Health;
        }
    }
 
    public void TakeDamage(int amount)
    {
        m_Combat.TakeDamage(amount);
    }
 
    public Vector3 Position
    {
        get
        {
            return m_Combat.Position;
        }
    }
 
    public Vector3 Velocity
    {
        get
        {
            return m_Combat.Velocity;
        }
    }
 
    public void Move(float elapsedSeconds)
    {
        m_Combat.Move(elapsedSeconds);
    }
}

The unit test can then be updated to use this proxy interface:

[Test]
void TakeDamageIsClampedToZero()
{
    PlayerCombat combat = new PlayerCombat(10);
    PlayerMovement movement = new PlayerMovement(Vector3.zero, Vector3.zero);
    Player player = new Player(combat, movement);
    player.TakeDamage(20);
    Assert.That(player.Health, Is.EqualTo(0));
}

The law of demeter violation is gone and this is much more object-oriented because in the real world we would say that “a player takes damage” instead of “a player’s combat takes damage.” However, we’ve introduced an even worse problem by making this change. Any time the PlayerCombat or PlayerMovement interfaces change, we’ll also need to update the Player class to make the same change. This coupling can also be considered a violation of the “single responsibility principal” which is what we sought out to rectify in the first place!

The core issue here is really the existence of a Player class in the first place. The structure of the object isn’t the problem, it’s that it’s an object in the first place. Small, focused types like PlayerCombat and PlayerMovement are fast and easy to use. They’re a natural fit in Unity, too. We might express them as MonoBehaviour classes attached to a GameObject or as IComponentData structs attached to an entity in Unity’s ECS. In neither case would we create a class Player that represents the real-world concept, and simply by avoiding that we reap some very nice benefits.