Summary

Scripts in Unity communicate by holding references to each other and calling methods across those references. One script does not reach into another script’s variables directly. It calls a named method that the receiving script exposes as its public interface. This keeps each script focused on one job and makes the system predictable: when a bug appears, you know which script is responsible for which piece of state.

Key ideas

The core pattern: Script A holds a reference to Script B. When something happens in A, it calls a method on B.

CoinPickup.OnTriggerEnter2D
    -> gameManager.AddScore(10)
        -> GameManager updates score

CoinPickup knows nothing about how score is stored. GameManager knows nothing about what a coin is. They each do one job and communicate through a clear interface.

Separation of concerns: Give each script exactly one responsibility. Common jobs in a beginner Unity game:

ScriptResponsibility
PlayerMovementReads input, moves the player
PlayerHealthTracks health and responds to damage events
CoinPickupDetects player overlap and notifies GameManager
GameManagerOwns score, health and lives as the single source of truth
UIManagerReads from GameManager and updates display

The fat class anti-pattern: A single script that handles movement, score, health, UI, and enemy spawning is a fat class. It is hard to debug (a movement bug may be buried inside health code), impossible to reuse, and tends to grow without limit. When a script starts doing two clearly different things, split it.

Public API: The methods a script exposes to other scripts are its interface. Keep this interface small and intentional. Other scripts call methods. They do not directly read or write fields. Private state with public read-only accessors (GetScore(), GetHealth()) is the standard pattern.


In practice

Wiring the reference and calling across it:

// CoinPickup.cs: detect collection and notify GameManager
public class CoinPickup : MonoBehaviour
{
    [SerializeField] private GameManager gameManager;
 
    private void OnTriggerEnter2D(Collider2D other)
    {
        if (other.CompareTag("Player"))
        {
            gameManager.AddScore(10);  // CoinPickup does not touch score directly
            Destroy(gameObject);
        }
    }
}
// GameManager.cs: own and protect game state
public class GameManager : MonoBehaviour
{
    private int score = 0;
 
    public void AddScore(int amount)
    {
        if (amount <= 0) return;
        score += amount;
        Debug.Log("[GameManager] Score: " + score);
    }
 
    public int GetScore() => score;  // callers cannot write score directly
}

The full event chain:

Coin touched by player
  -> CoinPickup.OnTriggerEnter2D
      -> gameManager.AddScore(10)
          -> GameManager.score updated
              -> (UIManager reads GetScore() and updates text)

Each arrow is a method call across a reference. No script reaches into another script’s private fields.

Before and after: splitting a fat script:

// BEFORE: fat PlayerController handles everything
public class PlayerController : MonoBehaviour
{
    public int score = 0;
    public int health = 100;
    void Update() { /* movement, input, score, health, UI all here */ }
}
 
// AFTER: responsibilities split across scripts
// PlayerMovement.cs: only moves the player
// GameManager.cs: only owns score and health
// CoinPickup.cs: only handles coin collection events

Null safety

If an Inspector reference is not assigned, calling a method on it throws a NullReferenceException. Guard critical references in Start:

void Start()
{
    if (gameManager == null)
        Debug.LogError("[CoinPickup] gameManager reference not assigned!");
}

Alternatively, guard inline before calling:

if (gameManager != null)
    gameManager.AddScore(10);
else
    Debug.LogWarning("[CoinPickup] gameManager is null. Score not awarded.");

When multiple things need to react to one event

The reference pattern works well when one script talks to one other script. But what if the player dies and you need the health bar to disappear, an achievement to unlock, a death sound to play, and the camera to shake at the same time? Wiring four separate references into PlayerHealth means it needs to know about all four systems, which is exactly the kind of coupling that makes scripts hard to reuse.

C# events solve this. An event is a broadcast: the sender says “something happened” without knowing who is listening. Each listener signs up independently and responds in its own way.

Worked example: Player Died

Step 1: The sender declares the event.

// PlayerHealth.cs
public class PlayerHealth : MonoBehaviour
{
    public static event System.Action OnPlayerDied;  // broadcast
 
    private int health = 3;
 
    public void TakeDamage(int amount)
    {
        health -= amount;
        if (health <= 0)
            OnPlayerDied?.Invoke();  // anyone listening will hear it
    }
}

The ?.Invoke() means “fire only if someone is listening”. It is safe even if nothing has subscribed yet.

Step 2: Each listener subscribes independently.

// HealthBarUI.cs: hides itself when the player dies
public class HealthBarUI : MonoBehaviour
{
    private void OnEnable()  { PlayerHealth.OnPlayerDied += HideHealthBar; }
    private void OnDisable() { PlayerHealth.OnPlayerDied -= HideHealthBar; }
 
    private void HideHealthBar() => gameObject.SetActive(false);
}
// DeathSound.cs: plays a sound when the player dies
public class DeathSound : MonoBehaviour
{
    [SerializeField] private AudioSource audioSource;
 
    private void OnEnable()  { PlayerHealth.OnPlayerDied += PlayDeathSound; }
    private void OnDisable() { PlayerHealth.OnPlayerDied -= PlayDeathSound; }
 
    private void PlayDeathSound() => audioSource.Play();
}
// CameraShake.cs: shakes the camera when the player dies
public class CameraShake : MonoBehaviour
{
    private void OnEnable()  { PlayerHealth.OnPlayerDied += Shake; }
    private void OnDisable() { PlayerHealth.OnPlayerDied -= Shake; }
 
    private void Shake() { /* shake logic here */ }
}

PlayerHealth does not know that HealthBarUI, DeathSound, or CameraShake exist. Each listener plugs in by itself. You can add or remove listeners without touching PlayerHealth at all.

The subscribe / unsubscribe rule

Always unsubscribe in OnDisable (or OnDestroy). If a listener is destroyed but never unsubscribes, Unity will try to call a method on a dead object and throw a MissingReferenceException. The OnEnable / OnDisable pair is the standard safe pattern.

Comparison: reference vs event

SituationUse
One script needs to call one other scriptReference ([SerializeField])
One script needs to notify multiple unrelated listenersC# event or UnityEvent
Listeners may be added/removed at runtimeC# event
Designer needs to wire listeners in the InspectorUnityEvent (serialised)

What about ScriptableObject event channels?

unity-scriptableobjects can be used as event channels. The event lives in an asset rather than a script, so any scene object can subscribe without needing a reference to PlayerHealth. This is the next step up in decoupling and is worth exploring once you are comfortable with the C# event pattern above.


Practice

Build the simplest useful communication chain:

  1. CoinPickup detects the player with OnTriggerEnter2D.
  2. CoinPickup calls gameManager.AddScore(10).
  3. GameManager stores the score.
  4. UIManager reads the score and updates visible text.

Success test:

  • the coin script has no score variable
  • the UI script does not detect coins
  • GameManager is the only script that changes score
  • missing Inspector references produce clear error logs

Extension:

  • add a PlayerDied event and make two unrelated scripts react to it

Self-test

  1. Why should CoinPickup call AddScore instead of changing GameManager.score directly?
  2. What does “one script, one job” mean in a beginner Unity project?
  3. When is a direct Inspector reference the right communication pattern?
  4. When is a C# event better than a direct reference?
  5. Why should listeners unsubscribe in OnDisable?

Answers

  1. A method call protects GameManager state and keeps score rules in one place.
  2. Each script should own one responsibility, such as movement, score, pickup detection or UI.
  3. When one script needs to talk to one known script.
  4. When one event needs to notify several unrelated listeners.
  5. It prevents Unity from calling methods on destroyed or disabled listeners.