Actor Oriented Design in Unity Part 2
I said we’d most likely revisit Actor Oriented Design in Unity, and here we are! This time we’ll be focused on Actor Eventing/Messaging/Cues. To catch up on the first installment, please read more here
State Machines + Messaging ≈ Actor Oriented Design
State Machines plus Messaging doesn’t quite equal Actor Oriented Design, but it’s awfully close. Actors in our scene will enter different states of action such as telling the camera zoom in, fire a projectile, or display a new ship. When an actor in our scene enters a state, they can create events/messages/cues for other actors in the scene to take up a new states in the scene. Those other actors “listen” for those events/messages/cues, then enter the appropriate state.
So what’s different from our original approach?
Our first approach to Actor Oriented Design involved only the state machines; we explicitly called other actors in our scene to enter specific states. This works for very small, very simple systems, but as systems grow, this creates a typical software issue known as “tight coupling”. This means that our actors have to know about each other and their states. This causes several issues as your project begins the grow. Most namely, that actors will have to have references to each other in order to instruct one another to enter different states.
This example is how a tightly-coupled piece of code would act. You’ll see, all the asteroids have a reference to our Player Ship for the purpose of registering a score on the Player Ship. It’s easy for our Asteroid to register a score for our Player Ship, but it requires each asteroid to “know” about the Player Ship in order to increase the score. There is nothing functionally wrong with this approach, but it is a design flaw.
So when we applied this solution to Spaceschuter McGavin it became problematic over time. In Spaceschuter McGavin we have 1000+ asteroids needing to get a reference to our Player Ship. Each asteroid is responsible for instructing the Player Ship to register a new score if is shot down. We also have to update that reference for each of the 100’s of asteroids each time the player changes ships.
As I mentioned, this racks up a lot of time in the Update and FixedUpdate methods, constantly checking if a player blows an asteroid up. It’s better to apply a loosely-coupled solution based on a event/messaging/cue system to relay this information.
Loosely-Coupled vs. Tightly-Coupled
Here we’ll see the difference between tightly coupled and loosely coupled Actor Oriented Design code.
A seasoned programming mind might realize we’re not avoiding tightly coupled objects all together. We’re still introducing tight coupling, but through a third object (Actor Cues). Actor Cues only job is to couple objects together that need to communicate. The idea is to control the coupling through this third object when you need to facilitate communication, but otherwise the objects take care of themselves.
This loosely-coupled example is broken into 2 parts: Publishing an event/message/cue and Subscribing to a event/message/cue. An event/message/cue is nothing more than “something that has happened in the scene”. That’s it. The asteroids are simply screaming out to any actor that will listen that an Asteroid Actor has been shot down. If no actor is listening at the moment, the event/message/cue dies and nothing changes. However if some actor is listening for the “Shot Down” cue, they will react in whichever way they choose.
The benefits of loosely coupled code goes beyond code fragmentation. It supports the SOLID coding principle, namely the Dependency inversion principle: Depend upon abstractions, not concretions. That’s a lot of tech-talk for meaning “the code is in good form and should perform/change well over time”. That’s the crux of what we’re getting at; designing code in a manner that reduces further redesigns down the road.
I talk in abstracts a lot, so how about some code?
Dear reader, I agree, but be warned: I will glaze over my use of C# generics as this is meant to be demonstrative in nature and not a course on the C# language.
ActorCues
using System;
using System.Collections.Generic;
using System.Linq;
namespace Assets.Scripts.Utility
{
public interface IActor
{ }
public interface IActorCue<T> where T : IActor
{
T actor { get; }
}
public interface IActorCueCollection
{ }
public interface ICueSubscriber<T> where T : IActor
{
public void OnCue(IActorCue<T> cue);
}
public class ActorCues
{
private Dictionary<Type, IActorCueCollection> actorCueCollections = new Dictionary<Type, IActorCueCollection>();
public void Subscribe<TActor, TCue>(ICueSubscriber<TActor> cueSubscriber) where TActor : IActor
where TCue : IActorCue<TActor>
{
var actorType = typeof(TActor);
if (!actorCueCollections.ContainsKey(actorType))
actorCueCollections.Add(actorType, new ActorCues<TActor>());
var cues = actorCueCollections[actorType] as ActorCues<TActor>;
cues.Subscribe<TCue>(cueSubscriber);
}
public void Unsubscribe<TActor, TCue>(ICueSubscriber<TActor> cueSubscriber) where TActor : IActor
where TCue : IActorCue<TActor>
{
var actorType = typeof(TActor);
if (!actorCueCollections.ContainsKey(actorType))
actorCueCollections.Add(actorType, new ActorCues<TActor>());
var cues = actorCueCollections[actorType] as ActorCues<TActor>;
cues.Unsubscribe<TCue>(cueSubscriber);
}
public void Publish<TActor, TCue>(IActorCue<TActor> cue) where TActor : IActor
where TCue : IActorCue<TActor>
{
var actorType = typeof(TActor);
if (!actorCueCollections.ContainsKey(actorType))
actorCueCollections.Add(actorType, new ActorCues<TActor>());
var cues = actorCueCollections[actorType] as ActorCues<TActor>;
cues.Publish(cue);
}
}
public class ActorCues<TActor> : IActorCueCollection where TActor : IActor
{
private Dictionary<Type, HashSet<ICueSubscriber<TActor>>> subscribers = new Dictionary<Type, HashSet<ICueSubscriber<TActor>>>();
private Type ActorType { get => typeof(TActor); }
public void Subscribe<TCue>(ICueSubscriber<TActor> cueSubscriber) where TCue : IActorCue<TActor>
{
var type = typeof(TCue);
if (!subscribers.ContainsKey(type))
subscribers.Add(type, new HashSet<ICueSubscriber<TActor>>());
if (!subscribers[type].Contains(cueSubscriber))
subscribers[type].Add(cueSubscriber);
}
public void Unsubscribe<TCue>(ICueSubscriber<TActor> cueSubscriber) where TCue : IActorCue<TActor>
{
var type = typeof(TCue);
if (subscribers.ContainsKey(type))
if (subscribers[type].Contains(cueSubscriber))
subscribers[type].Remove(cueSubscriber);
}
public void Publish(IActorCue<TActor> cue)
{
var type = cue.GetType();
if (subscribers.ContainsKey(type))
foreach (var subscriber in subscribers[type].ToList())
{
subscriber.OnCue(cue);
}
}
}
}
It all starts with Actor Cues. This serves as our messaging bus to which events/messages/cues are published and subscribed to. It’s important to know that PubSub implementation, such as this one can be accomplished many ways, some solutions can even involve the Unity Messaging system. It’s a kind of “developer’s choice”. In our use case, my events/messages/cues include a reference to the object that published the event/mesasge/cue. Don’t worry if this code seems confusing, we won’t be modifying it beyond its current state, but instead consuming it by Publishing and Subscribing to it from various actors. We’ll host the ActorCues in our GameManager
public class GameManager : MonoBehaviour
{
public ActorCues actorCues = new ActorCues();
}
Publishing
using Assets.Scripts.Utility;
namespace Assets.Scripts.Asteroid.Cues
{
public class ShotDownCue : IActorCue<AsteroidObject>
{
public ShotDownCue(AsteroidObject actor)
{ this.actor = actor; }
public AsteroidObject actor { get; }
}
public class AsteroidObject : MonoBehaviour, IActor
{
public GameManager gameManager;
public int shotDownScore = 100;
public OnDestroyAsteroid()
{
gameManager.actorCues.Publish<AsteroidObject, ShotDownCue>(this);
}
}
So here we have a simple AsteroidObject and ShotDownCue object. Notice that the Asteroid object implements the IActor interface. When prompted to blow up, the asteroid will publish the ShotDownCue for our listeners to subscribe to. Easy peasy.
Subscribing
public class PlayerShipObject : MonoBehaviour, ICueSubscriber<ObstacleObject>
{
public GameManager gameManager;
private score = 0;
private OnEnable()
{
gameManager.actorCues.Subscribe<AsteroidObject, ShotDownCue>(this);
}
private OnDisable()
{
gameManager.actorCues.Unsubscribe<AsteroidObject, ShotDownCue>(this);
}
public void OnCue(IActorCue<ObstacleObject> cue)
{
if (cue is ShotDownCue)
AddScore(cue.actor.shotDownScore)
}
private void AddScore(int addedScore)
{
score = score + addedScore;
}
}
Here’s our Player Ship subscribing to the AsteroidObject‘s ShotDownCue. When a event/message/cue is received, it will adjust the score accordingly. Notice how we unsubscribe and subscribe to the event/message/cue on OnEnable and OnDisable? That’s so if this ship is disabled it will no longer receive updates for a score! Useful if the Player changes ships or dies before an asteroid is shot down. All those features in a tiny little bundle of code.
That’s a lot more code
Sadly, yes, with good design comes more code. As I stated in my previous article. The Messaging System was not authored because it wasn’t quite “needed” yet. That word: “Need” is a tough term to define in this instance. For me it meant: My actors aren’t chatty enough yet with each other (tightly coupled) to warrant a redesign.
Spaceschuter McGavin is a prototype that’s evolving into our final product. That means I purposely take shortcuts at times to get something quickly into prototyping. IF that prototype passes playtesting and ages, I refactor the code to take more permanent residence using good design measures. Add in the fact that I have several prototyped features all aging at different rates, and the result is I have to be selective in what I redesign. It’s a balancing act as we learn more and do more.
I’d be remiss if I didn’t say I wish I had fully implemented Actor Oriented Design with the messaging component in tact. Refactoring any amount of code is typically more bug prone than starting from scratch with the new code. However, I strategically picked a point in time where I knew I had to refactor my prototype code to adopt events/messages/cues before tight-coupling became an issue. For me, this became evident because I ran into a limitation with my tight-coupling I purposely put in place. A kind of bookmark0 to where I knew if I ran into that known limitation, it was time to refactor code before I pursued.
What’s the right choice for me?
If you’re reading this blog to learn about Actor Oriented Design in Unity, I recommend following the full pattern. Our experience with software architecture and the C# language is high so we’re able to plan phased roll-outs of designs. If you’re reading this blog to learn about HOW TO INTRODUCE Actor Oriented Design to your project, I’d recommend implementing the StateMachine first. THEN work on messaging after you feel you have a firm grasp on the StateMachine portion. However if you notice immediate coupling of actors in your scene, I wouldn’t hesitate to introduce the Event/Message/Cue system.
In Conclusion
Actor Oriented Design in Unity is a perfect match in our minds here at Delightful Games. Unity works well with any amount of Actor Oriented Design, so use as sparingly or aggressively as you will. Actor Oriented Design approach provides a powerful, organized programming platform you can grow into as your projects grow.
As an addendum, Because I chose not to implement the messaging (cues) features of Actor Oritented Design (AoD) until recently, it caused me to have to over implement the state engine aspect of AoD, which I’m desconstructing now in sections of code as a result. Nothing has changed with the StateMachine or the cues functionally, I just need less states since I’m now handling messaging through the cues. So I stand by what I said by implementing AoD correctly and fully right from the start if you’re diving into AoD in one of your projects.