Awkward Objects
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.
#1 by Stephen Hodgson on April 17th, 2019 ·
Wonderful post. I’d like to add that making the movement and combat classes interfaces instead is interesting because then you could start doing complex composition of the objects which unlocks a more interesting form of polymorphism.
This would enable the development team to start working on multiple implementations for enemies, and other player class types, as well as making it super easy to test via the same interfaces calls.
#2 by Quan Nguyen on December 25th, 2019 ·
So, i have a problem with this approach: imagine
PlayerAbility
class with Enrage skill that affects speed value ofPlayerMovement
and attack value ofPlayerCombat
, how can i hook them up together? If i have a container object akaPlayer
class, i could easily do that. Otherwise, i can’ t seem to think of a good way doing that, particularly in Unity?#3 by jackson on December 29th, 2019 ·
One approach is to give the
PlayerAbility
object a reference to thePlayerMovement
object or thePlayer
object containing it. The ECS approach would be based on systems using these types of components.