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:
| Script | Responsibility |
|---|---|
PlayerMovement | Reads input, moves the player |
PlayerHealth | Tracks health and responds to damage events |
CoinPickup | Detects player overlap and notifies GameManager |
GameManager | Owns score, health and lives as the single source of truth |
UIManager | Reads 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 eventsNull 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
| Situation | Use |
|---|---|
| One script needs to call one other script | Reference ([SerializeField]) |
| One script needs to notify multiple unrelated listeners | C# event or UnityEvent |
| Listeners may be added/removed at runtime | C# event |
| Designer needs to wire listeners in the Inspector | UnityEvent (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:
CoinPickupdetects the player withOnTriggerEnter2D.CoinPickupcallsgameManager.AddScore(10).GameManagerstores the score.UIManagerreads the score and updates visible text.
Success test:
- the coin script has no score variable
- the UI script does not detect coins
GameManageris the only script that changes score- missing Inspector references produce clear error logs
Extension:
- add a
PlayerDiedevent and make two unrelated scripts react to it
Self-test
- Why should
CoinPickupcallAddScoreinstead of changingGameManager.scoredirectly? - What does “one script, one job” mean in a beginner Unity project?
- When is a direct Inspector reference the right communication pattern?
- When is a C# event better than a direct reference?
- Why should listeners unsubscribe in
OnDisable?
Answers
- A method call protects
GameManagerstate and keeps score rules in one place. - Each script should own one responsibility, such as movement, score, pickup detection or UI.
- When one script needs to talk to one known script.
- When one event needs to notify several unrelated listeners.
- It prevents Unity from calling methods on destroyed or disabled listeners.
Related
- unity-inspector-references - how to wire the reference in the Inspector
- unity-getcomponent - runtime alternative for same-GameObject access
- unity-gamemanager-pattern - the canonical central state manager
- csharp-methods - designing clear, single-responsibility methods
- csharp-delegates - the language-level foundation for events: delegate types, Action, Func, lambda expressions, multicast behaviour
- observer-pattern - the design pattern that C# events implement. Includes UnityEvent comparison and the full subject/observer architecture
- unity-scriptableobjects - ScriptableObject event channels for scene-independent decoupling