1
\$\begingroup\$

This is by far the biggest thing I always find myself going in circles over and can never seem to find an approach I'm happy with - setting up regular UnityEvent listeners from code vs. setting up "persistent event listeners" (Unity's term for event listeners we add to a UnityEvent in the Unity Inspector).

Let's say, as a very simple example, we've got a platformer where the player can pick up coins. We might have a player class that looks like this:

public class Player : MonoBehaviour {
    [SerializeField] private UnityEvent collectedCoinEvent;
    public UnityEvent CollectedCoinEvent => collectedCoinEvent;

    private void OnCollisionEnter(Collision collision) {
        var coin = collision.transform.GetComponent<Coin>();
        if (coin != null) {
            coin.ReturnToPool();
            collectedCoinEvent.Invoke();
        }
    }
}

Say we then add a CoinTracker which tracks the coins collected. Here are two ways we could write the class:

  1. For use with a persistent listener from the inspector:
public class CoinTracker : MonoBehaviour {
    [SerializeField] private TMP_Text coinsText;
    private int coins = 0;

    //this function is called by a persistent listener on the Player's 
    //collectedCoinEvent in the Inspector
    public void AddCoin() {
        coins++;
        coinsText.text = coins.ToString();
    }
}

This solution reduces coupling and makes it easier for someone who is not a programmer to set up the event logic. However, it makes navigating and understanding the code more difficult. Our IDE will show that there are 0 references to AddCoin() and 0 references to the CollectedCoinEvent property. Figuring out what happens when collectedCoinEvent is invoked is not too difficult (check the Inspector) but it's much more difficult to figure out what persistent event listeners might be calling the AddCoin() function.

  1. Add the event listener from code:
public class CoinTracker : MonoBehaviour {
    [SerializeField] private TMP_Text coinsText;
    [SerializeField] private Player player;
    private int coins = 0;

    private void Awake() {
        Assert.IsNotNull(player);
        player.CollectedCoinEvent.AddListener(AddCoin);
    }    

    private void AddCoin() {
        coins++;
        coinsText.text = coins.ToString();
    }
}

With this approach, since we add the event listener in code, we can easily follow the event flow in our IDE using features such as "find references". However, we've created coupling - the CoinTracker needs a reference to the Player instance. Additionally, team members who only work with the Editor and not with code wouldn't be able to add or remove event logic.

  1. A third approach uses an intermediary class for events, as an alternative to approach #2. This requires changes to the Player as well.
public class GameEventService : MonoBehaviour {
    private UnityEvent collectedCoinEvent = new UnityEvent();
    public UnityEvent CollectedCoinEvent => collectedCoinEvent ;
}

public class Player : MonoBehaviour {
    [SerializeField] private GameEventService gameEventService;
    
    private void OnCollisionEnter(Collision collision) {
        var coin = collision.transform.GetComponent<Coin>();
        if (coin != null) {
            coin.ReturnToPool();
            gameEventService.CollectedCoinEvent.Invoke();
        }
    }
}

public class CoinTracker : MonoBehaviour {
    [SerializeField] private TMP_Text coinsText;
    [SerializeField] private GameEventService gameEventService;
    private int coins = 0;

    private void Awake() {
        Assert.IsNotNull(player);
        gameEventService.CollectedCoinEvent.AddListener(AddCoin);
    }    

    private void AddCoin() {
        coins++;
        coinsText.text = coins.ToString();
    }
}

With this approach, we've eliminated coupling directly between CoinTracker and Player, but both classes are now coupled to GameEventService. While I've used UnityEvents for the example, we could do the same thing with C# events. As with #2, team members who only work with the Editor and not with code wouldn't be able to add or remove event logic.


Is there a right answer here? A rule for when to use a particular approach? A better alternative?

\$\endgroup\$
6
  • 2
    \$\begingroup\$ Rider (jetbrains.com/rider) will let you find references in the inspector from code. \$\endgroup\$
    – Evorlor
    May 13, 2023 at 0:47
  • \$\begingroup\$ And I would have put that coin collision check on the coin, not the player \$\endgroup\$
    – Zibelas
    May 13, 2023 at 6:23
  • 1
    \$\begingroup\$ @Zibelas This is very simple code I wrote specifically for the sake of illustrating this question, not production code. I'm not even working on a platformer game. Although, I'm curious... why would you put it on the coin? \$\endgroup\$
    – Kevin
    May 15, 2023 at 17:32
  • \$\begingroup\$ I would place it on the coin to give the coin the logic what to do. Same as what happens when you collect a power up. It makes your player class easier to read once the logic is more complicated than increasing a counter. \$\endgroup\$
    – Zibelas
    May 16, 2023 at 5:49
  • \$\begingroup\$ @Zibelas That makes sense, but if there are hundreds of coins in a scene, you're talking about adding hundreds of event listeners instead of just one, which increases scene complexity and could negatively impact scene load times (especially on mobile). I suppose a possible mitigation would be not loading all of the coins at once. \$\endgroup\$
    – Kevin
    May 16, 2023 at 17:20

1 Answer 1

1
\$\begingroup\$

Inspector links and "classic" events both have their use IMO (I'm not a fan of event managers). The general rule that I like is inspector event links inside the same prefab and code links for the more "distant" connections.

Persistent UnityEvent links allow for creating very small/universal components that can then be linked through inspector in countless ways to achieve many different complex behaviors (especially if you limit the possible event parameter types to the few most often used ones). But as you said they are a bit difficult to track, that is why I think it is best to limit inspector links to the same prefab and keep them contained. In other words no adding of inspector event links in the scene itself. BTW I look similarly and at the inspector references, as prefab inner wiring.

Lets say the coin example - inspector link inside the coin prefab that starts some particle effect when collected and code link for the distant UI overlay that displays the number of collected coins.

\$\endgroup\$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .