Skip to main content

4.5 - Scope and Lifetime of Variables

Understanding variable scope and lifetime is crucial for writing effective C# code. These concepts determine where variables can be accessed and how long they exist in memory, which directly impacts how you structure your game code in Unity.

What is Variable Scope?​

Scope refers to the region of code where a variable is accessible. In C#, scope is typically defined by curly braces { }. Variables are only accessible within the scope where they are declared.

Types of Variable Scope​

1. Block Scope​

Variables declared within a block (code enclosed in curly braces) are only accessible within that block:

{
int blockVariable = 10; // Only accessible within these braces
Console.WriteLine(blockVariable); // Works fine
}

// Console.WriteLine(blockVariable); // Error: blockVariable doesn't exist here

2. Method Scope (Local Variables)​

Variables declared within a method are called local variables and are only accessible within that method:

void ExampleMethod()
{
int localVariable = 5; // Local variable - only exists in this method
Console.WriteLine(localVariable); // Works fine
}

void AnotherMethod()
{
// Console.WriteLine(localVariable); // Error: localVariable doesn't exist here
}

3. Class Scope (Member Variables or Fields)​

Variables declared at the class level (outside any method) are called fields or member variables and are accessible throughout the class:

public class Player
{
// Class-level variables (fields)
private int health = 100;
private string playerName = "Hero";

void TakeDamage(int amount)
{
health -= amount; // Can access health here
Console.WriteLine($"{playerName} took {amount} damage!"); // Can access playerName here
}

void Heal(int amount)
{
health += amount; // Can access health here too
}
}

4. Nested Scope​

Inner scopes can access variables from outer scopes, but not vice versa:

void ExampleMethod()
{
int outerVariable = 10;

if (outerVariable > 5)
{
int innerVariable = 20;
Console.WriteLine(outerVariable); // Can access outer variable from inner scope
Console.WriteLine(innerVariable); // Can access inner variable
}

Console.WriteLine(outerVariable); // Can access outer variable
// Console.WriteLine(innerVariable); // Error: innerVariable only exists in the if block
}

Variable Lifetime​

Lifetime refers to how long a variable exists in memory during program execution.

1. Local Variable Lifetime​

Local variables (declared inside methods) exist only while the method is executing:

void TemporaryMethod()
{
int tempValue = 100; // Created when method starts
Console.WriteLine(tempValue);
} // tempValue is destroyed when method ends

void Update()
{
int frameCounter = 0; // Created anew each time Update runs
frameCounter++; // Always 1, never increases beyond that
Console.WriteLine(frameCounter);
}

2. Class-Level Variable Lifetime​

Class-level variables (fields) exist as long as the object (instance of the class) exists:

public class Enemy
{
private int health = 100; // Created when Enemy object is instantiated
private int killCount = 0; // Created when Enemy object is instantiated

public void Attack()
{
killCount++; // This value persists between method calls
}

public int GetKillCount()
{
return killCount; // Returns the current value
}
} // health and killCount are destroyed when the Enemy object is destroyed

3. Static Variable Lifetime​

Static variables exist for the entire duration of the program, regardless of instances:

public class GameManager
{
public static int TotalScore = 0; // Created when program starts
public static int EnemiesDefeated = 0; // Created when program starts

public void DefeatEnemy(int scoreValue)
{
TotalScore += scoreValue; // Updates the shared static variable
EnemiesDefeated++; // Updates the shared static variable
}
} // TotalScore and EnemiesDefeated exist until the program ends

Scope and Lifetime in Unity​

In Unity, understanding scope and lifetime is particularly important:

MonoBehaviour Script Lifecycle​

using UnityEngine;

public class PlayerController : MonoBehaviour
{
// Class-level variables - exist as long as the GameObject exists
private int health = 100;
private float moveSpeed = 5f;

// Called once when the script instance is loaded
void Awake()
{
int setupValue = 10; // Local to Awake method
health += setupValue;
} // setupValue is destroyed here

// Called once before the first frame update
void Start()
{
string startMessage = "Player initialized!"; // Local to Start method
Debug.Log(startMessage);
} // startMessage is destroyed here

// Called once per frame
void Update()
{
float horizontalInput = Input.GetAxis("Horizontal"); // Local to this Update call
float verticalInput = Input.GetAxis("Vertical"); // Local to this Update call

// These variables are recreated every frame
Vector3 movement = new Vector3(horizontalInput, 0, verticalInput);
transform.Translate(movement * moveSpeed * Time.deltaTime);
} // horizontalInput, verticalInput, and movement are destroyed here
}

Common Scope Issues in Unity​

1. Resetting Variables in Update​

A common mistake is declaring variables in Update() that should persist between frames:

// Incorrect approach
void Update()
{
int enemiesSpotted = 0; // Reset every frame!

// Check for enemies...
if (SeeNewEnemy())
{
enemiesSpotted++; // Will never go above 1
}

Debug.Log($"Enemies spotted: {enemiesSpotted}");
}

// Correct approach
private int enemiesSpotted = 0; // Class-level variable

void Update()
{
// Check for enemies...
if (SeeNewEnemy())
{
enemiesSpotted++; // Will accumulate properly
}

Debug.Log($"Enemies spotted: {enemiesSpotted}");
}

2. Accessing Destroyed Variables​

private GameObject target;

void Start()
{
target = GameObject.Find("Enemy");
}

void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Bullet"))
{
Destroy(target);
}
}

void Update()
{
// This might cause a NullReferenceException if target was destroyed
if (target != null) // Always check if the reference is still valid
{
float distance = Vector3.Distance(transform.position, target.transform.position);
Debug.Log($"Distance to target: {distance}");
}
}

Variable Shadowing​

Variable shadowing occurs when a variable in an inner scope has the same name as a variable in an outer scope:

public class Player
{
private int health = 100; // Class-level variable

void UpdateHealth(int amount)
{
int health = 50; // Local variable shadows the class-level variable
health += amount; // This modifies the local variable, not the class-level one

// The class-level health is still 100!
Debug.Log($"Local health: {health}");
Debug.Log($"Class health: {this.health}"); // Use 'this' to access the class-level variable
}
}

This is generally considered bad practice and should be avoided to prevent confusion.

Best Practices​

  1. Minimize Scope: Declare variables in the smallest scope possible
  2. Descriptive Names: Use clear, descriptive variable names to avoid confusion
  3. Avoid Shadowing: Don't use the same variable name in different scopes
  4. Consider Lifetime: Be mindful of when variables are created and destroyed
  5. Class Variables: Only make variables class-level if they need to persist between method calls
  6. Null Checks: Always check if references are valid before using them

Practical Example: Inventory System​

Here's a practical example showing how scope and lifetime affect a simple inventory system:

using UnityEngine;
using System.Collections.Generic;

public class InventorySystem : MonoBehaviour
{
// Class-level variables - persist throughout the object's lifetime
private List<string> inventory = new List<string>();
private int maxItems = 10;

// Public method to add an item
public bool AddItem(string itemName)
{
// Local variable - only exists during this method call
bool success = false;

if (inventory.Count < maxItems)
{
inventory.Add(itemName);
success = true;

// Local scope within the if statement
string message = $"Added {itemName} to inventory!";
Debug.Log(message);
}
else
{
Debug.Log("Inventory is full!");
}

return success;
// 'success' and 'message' variables no longer exist after this point
}

// Public method to use an item
public void UseItem(string itemName)
{
// Local variable - only exists during this method call
bool itemFound = false;

for (int i = 0; i < inventory.Count; i++)
{
// 'i' is local to this for loop
if (inventory[i] == itemName)
{
Debug.Log($"Using {itemName}!");
inventory.RemoveAt(i);
itemFound = true;
break;
}
}

if (!itemFound)
{
Debug.Log($"You don't have {itemName} in your inventory!");
}
// 'itemFound' and 'i' no longer exist after this point
}

// Public method to display inventory
public void DisplayInventory()
{
if (inventory.Count == 0)
{
Debug.Log("Inventory is empty!");
return;
}

// Local variable - only exists during this method call
string inventoryContents = "Inventory: ";

for (int i = 0; i < inventory.Count; i++)
{
inventoryContents += inventory[i];
if (i < inventory.Count - 1)
{
inventoryContents += ", ";
}
}

Debug.Log(inventoryContents);
// 'inventoryContents' no longer exists after this point
}
}

Unity-Specific Considerations​

In Unity, variable scope and lifetime have additional implications that are crucial to understand for effective game development:

MonoBehaviour Lifecycle and Variable Lifetime​

Unity's component lifecycle affects when variables are created and destroyed:

using UnityEngine;

public class PlayerController : MonoBehaviour
{
// Class-level variables - created when the component is instantiated
// and destroyed when the GameObject is destroyed
private int health = 100;

// Static variables - shared across all instances and persist until the game ends
// or the scene is unloaded (if not using DontDestroyOnLoad)
public static int TotalPlayers = 0;

// Awake is called when the script instance is being loaded
void Awake()
{
// Variables declared here exist only during Awake
Debug.Log("Player awakening...");
TotalPlayers++;
}

// Start is called before the first frame update
void Start()
{
// Variables declared here exist only during Start
float startupBonus = 20f;
health += (int)startupBonus;
Debug.Log($"Player started with {health} health");
}

// Update is called once per frame
void Update()
{
// Variables declared here are recreated EVERY FRAME
// This is inefficient for values that don't need to change each frame
Vector3 moveDirection = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
transform.Translate(moveDirection * Time.deltaTime * 5f);
}

// OnDestroy is called when the GameObject is destroyed
void OnDestroy()
{
TotalPlayers--;
Debug.Log("Player destroyed");
}
}

Inspector Variables and Serialization​

  1. SerializeField: Private variables marked with [SerializeField] are visible in the Inspector but maintain their private scope in code:
// Private in code, but visible and editable in the Inspector
[SerializeField] private float moveSpeed = 5f;

// Public variables are automatically visible in the Inspector
public int maxHealth = 100;

// Private variables without SerializeField are not visible in the Inspector
private int currentScore = 0;
  1. Prefab Instantiation: When instantiating prefabs, each instance gets its own copy of class-level variables:
public class Enemy : MonoBehaviour
{
// Each enemy instance will have its own health value
[SerializeField] private int health = 100;

// This is shared across ALL enemy instances
public static int EnemyCount = 0;

void Awake()
{
EnemyCount++;
}

void OnDestroy()
{
EnemyCount--;
}
}
  1. Scene Loading: Variables are reset when scenes are loaded unless specifically preserved:
public class GameManager : MonoBehaviour
{
// This will be reset when a new scene loads
public int currentScore = 0;

void Awake()
{
// This prevents the GameObject from being destroyed when loading a new scene
DontDestroyOnLoad(gameObject);
}

// Now this GameManager and its variables will persist across scene loads
}
  1. Component References: References to other components or GameObjects may become invalid if those objects are destroyed:
public class Weapon : MonoBehaviour
{
// This reference could become null if the target is destroyed
public Transform target;

void Update()
{
// Always check if references are still valid before using them
if (target != null)
{
// Safe to use target
transform.LookAt(target);
}
else
{
// Handle the case where target is null
Debug.Log("Target is missing!");
}
}
}

Common Unity-Specific Scope Issues​

1. Coroutine Variables​

Variables in coroutines have special considerations:

public class CoroutineExample : MonoBehaviour
{
private int score = 0;

void Start()
{
StartCoroutine(AddPointsOverTime());
}

IEnumerator AddPointsOverTime()
{
// Local to this coroutine, but persists between yields
int pointsToAdd = 10;

while (true)
{
score += pointsToAdd;
Debug.Log($"Score: {score}");

// This variable persists after the yield
pointsToAdd += 5;

yield return new WaitForSeconds(1f);
// The coroutine pauses here, but pointsToAdd still exists when it resumes
}
}
}

2. Event Callbacks​

Variables in event callbacks follow normal scope rules:

public class ButtonHandler : MonoBehaviour
{
public void OnButtonClick()
{
// Local to this method call
string message = "Button was clicked!";
Debug.Log(message);

// This variable doesn't exist after the method completes
}
}

Summary​

  • Scope determines where variables can be accessed in your code
  • Lifetime determines how long variables exist in memory
  • Local variables exist only during method execution
  • Class-level variables exist as long as the object exists
  • Static variables exist for the entire program duration
  • Understanding scope and lifetime helps prevent bugs and memory issues
  • In Unity, proper variable scope management is essential for creating efficient, bug-free games

In the next section, we'll explore recursion, a powerful technique where methods call themselves to solve complex problems.