Introduction
This article will address topics related to the implementation of the “soft coupling” of game logic components based on the message system when developing games on Unity3D.
It is not a secret for anybody that in the overwhelming majority of cases the means provided by the engine in its basic form are not enough to fully implement data exchange systems between game components. In the most primitive version, from which everyone starts, we get information through an object instance. This instance can be obtained in various ways from a reference to the scene object, to the Find functions. This is not convenient, makes the code non-flexible, and forces the programmer to provide for a variety of non-standard logic behaviors: from “the object disappeared from the scene” to “the object is not active”. Among other things, the speed of the written code may suffer.
Before we begin to consider ways to solve a problem, let us dwell on its assumptions and basic terms, which will be mentioned below. To begin with, what is meant by a “soft bond”. In this case, it is the exchange of data between the components of the game logic in such a way that these components know absolutely nothing about each other. On this basis, any references to objects of the scene or the search for an object in the scene by name or type give us a “hard link”. If these connections begin to line up in chains, then in case of need to change the behavior of logic, the programmer will have to reconfigure everything anew. As it is not difficult to guess, the flexibility here does not smell. Of course, you can write an extension for the editor that will automatically fill in the links, but this does not solve another problem - component-wise testing of game logic.
To make the above written more understandable, consider a simple example. In the logic of our spherical game there are several components: a weapon, an enemy, and a bullet. Having made a shot from the weapon, we should receive the following information: whether or not a bullet hit the enemy, if it hit, how much damage it caused to the enemy, if it did damage, then the enemy died or not. In addition, we have to transfer some of this information to other components, such as a graphical interface, which will show us the amount of damage done, the amount of health the enemy has and the amount of ammunition in the weapon. This may also include the display of the corresponding effects of the shot, hit, and also animation, etc ... It is not difficult to imagine the number of relationships and the transmitted data. Implementing this on a “hard link” can be, however, what happens if we need to test the logic of a bullet, if we still have no weapons and no enemies, or test the logic of the interface, but we have neither weapon logic nor enemies, no bullets, or we wanted to replace the bullet with a rocket. It is precisely the solution of this problem that dictates the need to create “soft connection” systems, which will allow us to easily imitate various components, even if they do not already exist, and also to change them without changing the code associated with them.
')
Let us dwell in more detail on the basic principle of the implementation of the “soft connection”. As mentioned above, in order to “softly” connect the two components, we must transfer data from one to another, so that they do not know anything about each other. In order to ensure this, we need to get data not on request (having an object instance on hand), but to use a notification mechanism. In fact, this means that when any events occur in the object / component, we do not ask this object about its state, the object itself notifies that changes have occurred in it. A set of such notifications forms an interface (not to be confused with an interface in C #), through which the game logic gets data about our object. It can be visualized as follows:

Thus, any component of the system through the notification interface can get the necessary data about the object of the game logic, while the presence of the object itself for testing the related components is optional, we just have to implement the interface and then replace it with a working instance.
Let us consider in more detail how to implement the mechanism described above, as well as their pros and cons.
UnityEvents / UnityAction based messaging system
This system appeared relatively recently (in version 5 of the Unity3D engine). An example of how to implement a simple message system can be viewed at this
link .
Advantages of using this method:
Minuses:
- Built-in Unity feature (not always native systems are better).
- Not flexible due to the use of UnityAction (although this can be bypassed), which gives a limit on the number of parameters (four maximum).
- Not flexible because of the difficulties with changing the parameters of the message, as it is necessary in many places of the code to change types, handlers, etc.
- It is not clear why to use if there is an event / delegate in C #.
Classic C # on Event / Delegate
The easiest and most efficient way to implement communication of components based on notifications is to use the
event / delegate pair, which is part of the C # language (for more information, see the articles on habr or msdn).
There are many different options for implementing a message system based on
event / delegate , some of them can be found on the Internet. I will give an example, in my opinion, the most convenient system, but first I want to mention one important detail associated with the use of event. If the event has no subscribers, then an error will occur when the event is triggered, so a null check is required before use. This is not very convenient. Of course, you can write a wrapper for each event, where a check for null will be performed, but this is no longer convenient. Let's turn to implementation.
First, we define the message interface for our logic object: The call for notifications is done as follows (example): public partial class GameLogicObject : MonoBehaviour { public int Health = 100; void Start() { if (StartEvent != null) { StartEvent(); } StartCoroutine(ChangeHealth()); } IEnumerator ChangeHealth() { yield return new WaitForSeconds(1f); Health = Mathf.Clamp(Health - UnityEngine.Random.Range(1, 20), 0, 100); if (ChangeHealthEvent != null) { ChangeHealthEvent(Health); } if (Health == 0) { if (DeathEvent != null) { DeathEvent(); } }else { StartCoroutine(ChangeHealth()); } } }
The interface and logic are formed, now nothing prevents us from using it in any other place and subscribing to the necessary notifications: public class GUILogic : MonoBehaviour { public Text HealthInfo; public Text StateInfo; void OnEnable() { GameLogicObject.StartEvent += GameLogicObject_StartEventHandler; GameLogicObject.ChangeHealthEvent += GameLogicObject_ChangeHealthEventHandler; GameLogicObject.DeathEvent += GameLogicObject_DeathEventHandler; } private void GameLogicObject_DeathEventHandler() { StateInfo.text = "Im died"; } private void GameLogicObject_ChangeHealthEventHandler(int healthValue) { HealthInfo.text = healthValue.ToString(); } private void GameLogicObject_StartEventHandler() { StateInfo.text = "Im going"; } void OnDisable() { GameLogicObject.StartEvent -= GameLogicObject_StartEventHandler; GameLogicObject.ChangeHealthEvent -= GameLogicObject_ChangeHealthEventHandler; GameLogicObject.DeathEvent -= GameLogicObject_DeathEventHandler; } }
As you can see from the example, the subscription occurs in the
OnEnable method, and the unsubscribe in
OnDisable is , in this case, mandatory, otherwise a memory leak and a null reference exception are guaranteed if the object is removed from the game. The subscription itself can be done at any desired time, it is not necessary to do this only in
OnEnable .
It is easy to see that with this approach, we can test without any problems the work of the
GUILogic class, even in the absence of real
GameLogicObject logic. It is enough to write a simulator using the notification interface and use calls of the form
GameLogicObject.StartEvent ().
What are the benefits of this implementation:
- The standard mechanism of the language C #, as a result of 100% cross-platform without dancing with tambourines.
- The simplicity of the system implementation (without additional investments in the code).
Minuses:
- It is necessary to monitor the memory (unsubscribe from notifications). With large amounts of code, it is easy to forget to unsubscribe from one event and then catch bugs for a long time.
- It is necessary to unsubscribe from events if the scene object is deactivated for a while, otherwise the handler will be called for it.
- Not flexible due to the difficulties with changing the parameters of the message, as it is necessary in many places in the code to change the types, handlers, calls, etc.
- Please note that the event may not have subscribers.
Reflection message system with identification on string
Before proceeding to the description of the system and its implementation, I would like to highlight the prerequisites that pushed for its creation. Before coming to these thoughts in my applications, I used the system described above based on event / delegate. Those projects that I had to develop at that time were relatively simple, they needed speed of implementation, a minimum of bugs on the tests, the exception to the maximum of the human factor in the development phase of the game logic. Based on this, a number of some requirements regarding the exchange of data between components were born:
- Automatically subscribe to events.
- No need to keep track of memory (self-cleaning system).
- No need to follow subscribers, the system should work even if they are not.
The result of a brief reflection was the birth of the idea to use reflection through the attributes of class / component methods.
Identify the class method as an event handler: [GlobalMessanger.MessageHandler] void OnCustomEvent(int param) { }
GlobalMessanger.MessageHandler is an attribute that tells us that the method is an event handler. In order to determine the type of event that this method handles, there are two ways (although in fact there may be more):
- Specify event type in attribute parameters:
[GlobalMessanger.MessageHandler("CustomEvent")]
- Use event type in method name with “On” prefix (or any other). I use this method, because in 100% of cases, so as not to be confused, I name the methods in this way.
In order to make a subscription in automatic mode, again, there are two ways:
- Use the script that will search for all components on the object, and then, through reflection, look for methods with an attribute in them. In order not to add this script by hand, it will be enough in all components where it is needed, put down
[RequireComponent(typeof(AutoSubsciber))]
I personally find this method less convenient than the second, since it requires extra gestures.
- Creating a wrapper on the MonoBehaviour class:
CustomBehaviour public class CustomBehaviour : MonoBehaviour { private BindingFlags m_bingingFlags = BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy; protected void Subscribe(string methodName) { var method = this.GetType().GetMethod(methodName, m_bingingFlags); GlobalMessanger.Instance.RegisterMessageHandler(this, method); } protected void Unsubscribe(string methodName) { var method = this.GetType().GetMethod(methodName, m_bingingFlags); GlobalMessanger.Instance.UnregisterMessageHandler(this, method); } protected virtual void Awake() { var methods = this.GetType().GetMethods(m_bingingFlags); foreach(MethodInfo mi in methods) { if(mi.GetCustomAttributes(typeof(GlobalMessanger.MessageHandler), true).Length != 0) { GlobalMessanger.Instance.RegisterMessageHandler(this, mi); } } } }
As you can see, this wrapper contains two methods that allow you to subscribe and unsubscribe from the event (the type of event is taken from the method name). They are necessary if we need to subscribe to the event in the course of the logic. Automatic subscription is done in the Awake method. Cancellation of events is carried out automatically, but more on that later.
We define the subscription and event call management system: public class GlobalMessanger : MonoBehaviour { private static GlobalMessanger m_instance; public static GlobalMessanger Instance { get { if(m_instance == null) { var go = new GameObject("!GlobalMessanger", typeof(GlobalMessanger)); m_instance = go.GetComponent<GlobalMessanger>(); } return m_instance; } } public class MessageHandler : Attribute { } private class MessageHandlerData { public object Container; public MethodInfo Method; } private Hashtable m_handlerHash = new Hashtable(); }
The
GlobalMessanger class is a regular Unity component, accessed on the basis of a Unity-singleton. In this case, a separate scene object is created for this component, which exists only inside it and will be deleted when the scene is unloaded. Since our events are identified on the basis of strings, I decided to store information about events and subscribers in a hash table.
Now we need methods to register / delete subscribers: public void RegisterMessageHandler(object container, MethodInfo methodInfo) { var methodName = methodInfo.Name; var messageID = methodName.Substring(2); if (!m_handlerHash.ContainsKey(messageID)) { RegisterMessageDefinition(messageID); } var messageHanlders = (List<MessageHandlerData>)m_handlerHash[messageID]; messageHanlders.Add(new MessageHandlerData() { Container = container, Method = methodInfo }); } public void UnregisterMessageHandler(object container, MethodInfo methodInfo) { var methodName = methodInfo.Name; var messageID = methodName.Substring(2); if (m_handlerHash.ContainsKey(messageID)) { var messageHanlders = (List<MessageHandlerData>)m_handlerHash[messageID]; for (var i = 0; i < messageHanlders.Count; i++) { var mhd = messageHanlders[i]; if (mhd.Container == container && mhd.Method == methodInfo) { messageHanlders.Remove(mhd); return; } } } }
Next, we need a method to call events and subscribers to them: public void Call(string messageID, object[] parameter = null) { if (m_handlerHash.ContainsKey(messageID)) { var hanlderList = (List<MessageHandlerData>) m_handlerHash[messageID]; for(var i = 0; i < hanlderList.Count; i++) { var mhd = hanlderList[i]; var unityObject = (MonoBehaviour)mhd.Container; if (unityObject != null) { if (unityObject.gameObject.activeSelf) { mhd.Method.Invoke(mhd.Container, parameter); } } else { m_removedList.Add(mhd); } } for (var i = 0; i < m_removedList.Count; i++) { hanlderList.Remove(m_removedList[i]); } m_removedList.Clear(); } }
As you can see, when an event is triggered, a check is made for the existence of the object and the activity of the object. In the first case, the deleted object is removed from the subscribers, in the second it is ignored when calling event-handling methods. Thus, it is not necessary to follow the unsubscribing of events at the remote object, everything is done automatically. At the same time, if the object was temporarily deactivated, it is not possible to unsubscribe from the events and re-subscribe, as well as to call the presence of subscribers to the event is not necessary.
The last thing that is required of us is to clean up the scene after unloading: void OnDestroy() { foreach(var handlers in m_handlerHash.Values) { var messageHanlders = (List<MessageHandlerData>)handlers; messageHanlders.Clear(); } m_handlerHash.Clear(); m_handlerHash = null; }
It is easy to see that the system described above does not represent anything extraordinary and does not carry revelations, but it is simple and convenient and well suited for relatively small projects.
An example of using this system: I think it is immediately noticeable how much the code has reduced compared to the event / delegate, which personally pleases me.
What are the benefits of this implementation:
- Automatically subscribe to events.
- There is no need to follow the unsubscribe from the events (even in the case of a manual subscription).
- Relatively simple implementation.
- Convenient reading of the code, the attributes easily show who is the event handler and which ones.
- Less code than event / delegate
- There is no need to think that the event has no subscribers.
Minuses:
- Since the cancellation of events takes place in a deferred mode, on very large projects, it will probably take up extra memory, but this can be easily solved by manually unsubscribing events, just as it was shown in the section on event / delegate.
- Binding to strings, because if you want to make the event name more beautiful, you will have to change it in many places.
- There is no flexibility in the parameters and data types of events, changes require many actions on the code.
- There may be problems with cross-platform due to the use of reflection.
Reflection message system with identifications on data types
In the previous section, the system was described as more convenient (in my opinion) as compared to
event / delegate , but it still has a number of flaws that greatly affect the flexibility of our code, so the next step was its development taking these factors into account.
So, we need to retain all the advantages of the previous system, but to make it more flexible and more resistant to possible changes in the game logic. Since the main problem is the change of the name of the event and the parameters passed, the idea arose to identify the events by them. In fact, this means that any event that occurs in a component is characterized by nothing more than the data that it transmits. Since we cannot simply bind to standard types (int, float, etc.), because in logic such data can transmit many components, the logical step was to make a wrapper over them that would be convenient, easy to read, and unambiguously interpretive. event.
Thus, our messaging interface took the following form: public partial class GameObjectLogic { [GEvents.EventDescription(HandlerRequirement = GEvents.HandlerRequirementType.NotRequired)] public sealed class StartEvent : GEvents.BaseEvent<StartEvent> { } [GEvents.EventDescription(HandlerRequirement = GEvents.HandlerRequirementType.Required)] public sealed class ChangeHealthEvent : GEvents.BaseEvent<ChangeHealthEvent> { public int Value { get; private set; } public ChangeHealthEvent(int value) { Value = value; } } [GEvents.EventDescription(HandlerRequirement = GEvents.HandlerRequirementType.Required)] public sealed class DeathEvent : GEvents.BaseEvent<DeathEvent> { } }
As you can see, events have attributes. This gives us the opportunity to get debug information if the event requires a subscriber, but for some reason it is not in the code.
The Call event method (and its overloads), which we previously had in the
GlobalMessanger class, is now static and is in
GEvents.BaseEvent and now takes as an argument an instance of the class that describes the type of event.
The notification call code will now be like this: public partial class GameLogicObject : MonoBehaviour { public int Health = 100; void Start() { StartEvent.Call();
Subscribing and unsubscribing to events is carried out in the same way as before, through the method attributes, but now the event type is not identified by a string value (method name or attribute parameter), but by the method parameter type (in the example, these are StartEvent, ChangeHealthEvent and DeathEvent).
Example handler method: [GEvents.EventHandler] public void OnDeathEventHandler(GameLogicObject.DeathEvent ev) {
Thus, using the implementation described above, we received the greatest possible flexibility in the code, because now we can change the transmitted data in events without any significant costs, we just need to change the body of the handler for the new parameters. In the case, if we want to change the name of the event (class name), the compiler will tell us where the old version is used. In this case, the need to change the name of the handler method completely disappears.
Total
I tried to describe in this article all possible methods of building data exchange systems between components based on notifications in my subjective opinion. All these methods have been used by me in different projects and different complexity: from simple mobile projects to complex PCs. What system to use in your project is up to you.
PS: I deliberately did not describe in the article the construction of a message system based on SendMessage-functions, because compared to the others, it does not stand up to criticism not only in terms of convenience, but also in terms of speed.