One great tool for decoupling components in Unity are events.
The high-level idea of events is that one component says "Something happened with me" and other components can choose to subscribe to such events and then react on them. The component which posted the event does not need to know who listens to the event it invokes (or if anyone listens at all). And the components reacting to them do not need to know which component invoked the event. That means you have components which communicate with each other without even knowing about their existence.
Unity actually has several event architectures. I will present two here, the classic message broadcast events and the much newer UnityEvents.
Messages
One is the classic method SendMessage("OnDamage", damageAmount)
. It's part of the class Component
, so you can call it from any script. This method looks at all components of the same gameObject for any methods called "OnDamage" and calls those methods passing arbitrary data as an argument. The advantage of this technique is that it is very easy to implement. But it has two major drawbacks:
- It is limited to components on the same gameObject, so you can't use it for communication between different objects. OK, there are also BroadcastMessage that calls the methods on the object or any children, and SendMessageUpward that calls it on its parents. But both require knowledge of your object hierarchy. That's not really loose coupling. And you can't use it to send events to objects on another branch of the scene graph at all.
- The method names are "stringly typed". When you misspell the method name (for example "onDamage" instead of "OnDamage"), then nothing is going to tell you. The code just runs and nothing happens. Which is a symptom that can be caused by all kinds of causes. Which can be really frustrating to debug.
UnityEvents
A far better event technique, though, are Unity Events. For example, when you have a component which should be allowed to post damage events, then you simply add a new field of type UnityEvent to the class. Here is an example of such a script. This object continuously loses HP and invokes events which allows others to listen to that:
public class Damageable : MonoBehaviour
{
public float hp;
public float maxHp = 10;
public UnityEvent<float, float> damagedEvent;
void Start()
{
hp = maxHp;
}
void Update()
{
hp -= Time.deltaTime;
if (hp <= 0f) Destroy(gameObject);
damagedEvent.Invoke(hp, maxHp);
}
}
When you now look at the inspector of a gameObject with that component, then you will see that you have a new widget as you might know it from buttons in the input system:

You can now add any methods from any components of any gameObject to it (as long as the signature matches). For example, here is a component that controls a health bar:
public class HealthBar : MonoBehaviour
{
private Image myImage;
void Start()
{
myImage = GetComponent<Image>();
}
public void UpdateBar(float current, float max) {
myImage.fillAmount = current / max;
}
}
Note that it does not reference the Damageable script. But it has a method "UpdateBar" which takes two floats, just like the UnityEvent. That means we can use the inspector to bind this method to the event of the damageable object. Just go to the inspector of the event, click the "+" icon, assign the health bar to the "Object" field and select the function "HealthBar->UpdateBar" in the function dropdown. It should now look like this:

Now when the damageable invokes the damagedEvent, the health bar gets updated. Without the health bar knowing that the Damageable exists, or the Damageable knowing about the health bar. That's loose coupling!
You can also set up such event subscriptions at runtime. For example, when you have a different component on the same gameObject which should always react to the damageEvent of the Damageable component, you would do this:
void Start() {
GetComponent<Damageable>().damagedEvent.AddListener(OnDamage)
}
private void OnDamage(float current, float max) {
Console.Log($"Ouch, I am down to {current} hp!");
}