Skip to main content

7.5 - throw Statement

So far, we've focused on handling exceptions that occur during program execution. Now, we'll explore how to create and throw your own exceptions using the throw statement. This is an essential skill for communicating error conditions in your code and building robust game systems.

Understanding the throw Statement

The throw statement allows you to:

  1. Create and throw new exceptions
  2. Re-throw caught exceptions
  3. Signal error conditions that cannot be handled locally
  4. Provide detailed information about what went wrong

Basic Syntax

The basic syntax of the throw statement is:

throw expression;

Where expression is an object of a type derived from System.Exception.

Creating and Throwing Exceptions

The most common use of throw is to create and throw a new exception:

public void SetPlayerHealth(int value)
{
if (value < 0)
{
throw new ArgumentException("Health cannot be negative", nameof(value));
}

playerHealth = value;
}

In this example:

  • We check if the health value is valid
  • If not, we throw an ArgumentException with a descriptive message
  • We include the parameter name for better debugging

Common Exception Types to Throw

C# provides many built-in exception types for different scenarios:

Exception TypeWhen to Use It
ArgumentExceptionWhen an argument value is invalid
ArgumentNullExceptionWhen an argument is unexpectedly null
ArgumentOutOfRangeExceptionWhen an argument is outside the valid range
InvalidOperationExceptionWhen the operation is invalid for the object's current state
NotImplementedExceptionWhen a method or property is not yet implemented
IOExceptionWhen an I/O error occurs
FileNotFoundExceptionWhen a file cannot be found
UnauthorizedAccessExceptionWhen access to a resource is denied
FormatExceptionWhen a string format is invalid
TimeoutExceptionWhen an operation times out

Let's see examples of using these exception types in game development:

// ArgumentException
public void SetDifficulty(int level)
{
if (level < 1 || level > 5)
{
throw new ArgumentException("Difficulty must be between 1 and 5", nameof(level));
}

gameDifficulty = level;
}

// ArgumentNullException
public void RegisterPlayer(Player player)
{
if (player == null)
{
throw new ArgumentNullException(nameof(player), "Player cannot be null");
}

players.Add(player.Id, player);
}

// ArgumentOutOfRangeException
public Item GetInventoryItem(int index)
{
if (index < 0 || index >= inventory.Count)
{
throw new ArgumentOutOfRangeException(nameof(index),
$"Index must be between 0 and {inventory.Count - 1}");
}

return inventory[index];
}

// InvalidOperationException
public void StartGame()
{
if (players.Count < minPlayers)
{
throw new InvalidOperationException(
$"Cannot start game with fewer than {minPlayers} players");
}

gameState = GameState.Playing;
}

// NotImplementedException
public virtual void SpecialAbility()
{
// Base class doesn't implement this - derived classes should override
throw new NotImplementedException("SpecialAbility must be implemented by derived classes");
}

// IOException / FileNotFoundException
public SaveData LoadGame(string saveName)
{
string path = Path.Combine(Application.persistentDataPath, $"{saveName}.sav");

if (!File.Exists(path))
{
throw new FileNotFoundException($"Save file '{saveName}' not found", path);
}

try
{
string json = File.ReadAllText(path);
return JsonUtility.FromJson<SaveData>(json);
}
catch (IOException e)
{
throw new IOException($"Error reading save file '{saveName}'", e);
}
}

Throwing with Additional Information

When throwing exceptions, include as much relevant information as possible:

public Enemy SpawnEnemy(string enemyType, Vector3 position)
{
if (!enemyPrefabs.ContainsKey(enemyType))
{
string availableTypes = string.Join(", ", enemyPrefabs.Keys);

throw new ArgumentException(
$"Unknown enemy type: {enemyType}. Available types: {availableTypes}",
nameof(enemyType));
}

// Spawn the enemy...
return Instantiate(enemyPrefabs[enemyType], position, Quaternion.identity);
}

This exception message not only tells you what went wrong but also what valid options are available.

Re-throwing Exceptions

Sometimes you want to catch an exception, perform some action, and then re-throw it:

1. Simple Re-throw

public void ProcessLevel(string levelName)
{
try
{
LoadLevel(levelName);
}
catch (Exception e)
{
Debug.LogError($"Error loading level: {e.Message}");

// Re-throw the same exception
throw;
}
}

Using throw; (without an expression) preserves the original exception's stack trace, which is usually what you want.

2. Wrapping Exceptions

Sometimes you want to catch a low-level exception and throw a higher-level one:

public PlayerData LoadPlayerProfile(string profileId)
{
try
{
string path = Path.Combine(Application.persistentDataPath, $"Profiles/{profileId}.json");
string json = File.ReadAllText(path);
return JsonUtility.FromJson<PlayerData>(json);
}
catch (FileNotFoundException e)
{
// Wrap in a more specific exception
throw new ProfileNotFoundException($"Profile '{profileId}' not found", e);
}
catch (JsonException e)
{
// Wrap in a more specific exception
throw new ProfileCorruptedException($"Profile '{profileId}' is corrupted", e);
}
}

Note how we pass the original exception as the innerException parameter. This preserves the original error information while adding context.

Exception Propagation

When an exception is thrown, it "bubbles up" the call stack until it's caught or reaches the top level:

public void StartGame()
{
try
{
InitializeGameWorld();
SpawnPlayer();
StartGameLoop();
}
catch (Exception e)
{
Debug.LogError($"Game initialization failed: {e.Message}");
ShowErrorScreen("Failed to start game. Please try again.");
}
}

private void InitializeGameWorld()
{
LoadLevel("Level1");
SpawnEnemies();
}

private void LoadLevel(string levelName)
{
if (!levelData.ContainsKey(levelName))
{
throw new ArgumentException($"Level '{levelName}' not found", nameof(levelName));
}

// Load the level...
}

In this example:

  1. LoadLevel throws an exception
  2. The exception propagates up to InitializeGameWorld
  3. Since InitializeGameWorld doesn't catch it, it continues up to StartGame
  4. StartGame catches the exception and handles it

This propagation allows you to handle exceptions at the appropriate level of abstraction.

When to Throw Exceptions

Exceptions should be used for exceptional conditions, not for normal control flow. Here are guidelines for when to throw exceptions:

Good Reasons to Throw Exceptions

  1. Invalid arguments: When a method receives arguments that don't make sense
  2. Precondition violations: When a method is called in an inappropriate context
  3. Resource failures: When required resources are unavailable
  4. Unexpected states: When the system is in a state that shouldn't be possible
  5. External system failures: When interactions with external systems fail

Bad Reasons to Throw Exceptions

  1. Expected conditions: Use return values or out parameters instead
  2. Control flow: Use if/else statements or other control structures
  3. Performance-critical code: Exceptions have overhead
  4. Recoverable situations: If the caller can easily check beforehand

Game Development Examples

Let's look at some practical examples of using throw in game development:

1. Validating Game Configuration

public void InitializeGame(GameConfig config)
{
// Validate required configuration
if (config == null)
{
throw new ArgumentNullException(nameof(config), "Game configuration cannot be null");
}

if (string.IsNullOrEmpty(config.StartingLevel))
{
throw new ArgumentException("Starting level must be specified", nameof(config));
}

if (config.MaxPlayers < 1)
{
throw new ArgumentException("Max players must be at least 1", nameof(config));
}

if (config.Difficulty < GameDifficulty.Easy || config.Difficulty > GameDifficulty.Nightmare)
{
throw new ArgumentException("Invalid difficulty setting", nameof(config));
}

// Initialize the game with the validated configuration
currentLevel = config.StartingLevel;
maxPlayers = config.MaxPlayers;
difficulty = config.Difficulty;

Debug.Log($"Game initialized with difficulty: {difficulty}, max players: {maxPlayers}");
}

2. Resource Management

public Texture2D GetTexture(string textureName)
{
if (string.IsNullOrEmpty(textureName))
{
throw new ArgumentNullException(nameof(textureName), "Texture name cannot be null or empty");
}

// Check if the texture is already loaded
if (textureCache.TryGetValue(textureName, out Texture2D texture))
{
return texture;
}

// Try to load the texture
texture = Resources.Load<Texture2D>($"Textures/{textureName}");

if (texture == null)
{
throw new ResourceNotFoundException($"Texture '{textureName}' not found");
}

// Cache the texture for future use
textureCache[textureName] = texture;
return texture;
}

3. Game State Validation

public void TransitionToState(GameState newState)
{
// Check if the transition is valid
if (!IsValidTransition(currentState, newState))
{
throw new InvalidOperationException(
$"Cannot transition from {currentState} to {newState}");
}

// Perform state exit actions
ExitState(currentState);

// Update the state
GameState previousState = currentState;
currentState = newState;

// Perform state entry actions
try
{
EnterState(newState);
}
catch (Exception e)
{
// If entering the new state fails, revert to the previous state
Debug.LogError($"Failed to enter state {newState}: {e.Message}");
currentState = previousState;

// Re-throw the exception
throw;
}

Debug.Log($"Transitioned from {previousState} to {newState}");
}

private bool IsValidTransition(GameState from, GameState to)
{
// Define valid state transitions
switch (from)
{
case GameState.MainMenu:
return to == GameState.Loading || to == GameState.Options;

case GameState.Loading:
return to == GameState.Playing || to == GameState.MainMenu;

case GameState.Playing:
return to == GameState.Paused || to == GameState.GameOver;

case GameState.Paused:
return to == GameState.Playing || to == GameState.MainMenu;

case GameState.GameOver:
return to == GameState.MainMenu || to == GameState.Loading;

case GameState.Options:
return to == GameState.MainMenu;

default:
return false;
}
}

4. Custom Game Logic

public void CastSpell(Spell spell, Character caster, Character target)
{
// Validate spell
if (spell == null)
{
throw new ArgumentNullException(nameof(spell), "Spell cannot be null");
}

// Validate caster
if (caster == null)
{
throw new ArgumentNullException(nameof(caster), "Caster cannot be null");
}

// Validate target
if (target == null)
{
throw new ArgumentNullException(nameof(target), "Target cannot be null");
}

// Check if the caster has enough mana
if (caster.CurrentMana < spell.ManaCost)
{
throw new InsufficientManaException(
$"Not enough mana to cast {spell.Name}. Required: {spell.ManaCost}, Current: {caster.CurrentMana}");
}

// Check if the spell is on cooldown
if (caster.IsSpellOnCooldown(spell))
{
float remainingCooldown = caster.GetRemainingCooldown(spell);
throw new SpellOnCooldownException(
$"{spell.Name} is on cooldown for {remainingCooldown:F1} more seconds");
}

// Check range
float distance = Vector3.Distance(caster.transform.position, target.transform.position);
if (distance > spell.Range)
{
throw new TargetOutOfRangeException(
$"Target is out of range. Maximum range: {spell.Range}, Current distance: {distance:F1}");
}

// Cast the spell
caster.CurrentMana -= spell.ManaCost;
caster.StartSpellCooldown(spell);
spell.Apply(caster, target);

Debug.Log($"{caster.Name} cast {spell.Name} on {target.Name}");
}

Creating Custom Exception Classes

For domain-specific errors, it's often useful to create custom exception classes:

// Base class for all game-related exceptions
public class GameException : Exception
{
public GameException() : base() { }
public GameException(string message) : base(message) { }
public GameException(string message, Exception innerException)
: base(message, innerException) { }
}

// Specific exception types
public class ResourceNotFoundException : GameException
{
public string ResourceName { get; }
public string ResourceType { get; }

public ResourceNotFoundException(string resourceName, string resourceType = "")
: base($"Resource not found: {resourceName} {(string.IsNullOrEmpty(resourceType) ? "" : $"(Type: {resourceType})")}")
{
ResourceName = resourceName;
ResourceType = resourceType;
}
}

public class InsufficientManaException : GameException
{
public int Required { get; }
public int Available { get; }

public InsufficientManaException(string message) : base(message) { }

public InsufficientManaException(int required, int available)
: base($"Insufficient mana. Required: {required}, Available: {available}")
{
Required = required;
Available = available;
}
}

public class SpellOnCooldownException : GameException
{
public float RemainingCooldown { get; }

public SpellOnCooldownException(string message) : base(message) { }

public SpellOnCooldownException(float remainingCooldown)
: base($"Spell is on cooldown for {remainingCooldown:F1} more seconds")
{
RemainingCooldown = remainingCooldown;
}
}

public class TargetOutOfRangeException : GameException
{
public float MaxRange { get; }
public float ActualDistance { get; }

public TargetOutOfRangeException(string message) : base(message) { }

public TargetOutOfRangeException(float maxRange, float actualDistance)
: base($"Target is out of range. Maximum range: {maxRange}, Current distance: {actualDistance:F1}")
{
MaxRange = maxRange;
ActualDistance = actualDistance;
}
}

Custom exceptions allow you to:

  1. Categorize errors more specifically
  2. Include domain-specific information
  3. Handle different error types differently
  4. Make your code more self-documenting

Using throw Expressions (C# 7.0+)

In C# 7.0 and later, you can use throw as an expression in certain contexts:

1. Conditional (Ternary) Operator

public Player GetPlayerById(string id)
{
return playerDictionary.ContainsKey(id)
? playerDictionary[id]
: throw new ArgumentException($"Player with ID {id} not found", nameof(id));
}

2. Null-Coalescing Operator

public void ProcessPlayerData(PlayerData data)
{
// If data is null, throw an exception
data = data ?? throw new ArgumentNullException(nameof(data));

// Process the data...
}

3. Expression-Bodied Members

public string GetRequiredSetting(string key) => 
settings.TryGetValue(key, out string value)
? value
: throw new KeyNotFoundException($"Required setting '{key}' not found");

Performance Considerations

Throwing exceptions has performance implications:

  1. Creating exception objects is relatively expensive
  2. Capturing stack traces adds overhead
  3. Unwinding the call stack takes time

For performance-critical code, consider alternatives:

1. Return Success/Failure Codes

// Instead of throwing exceptions
public bool TryGetEnemy(string id, out Enemy enemy)
{
if (enemyDictionary.TryGetValue(id, out enemy))
{
return true;
}

enemy = null;
return false;
}

// Usage
if (!TryGetEnemy("goblin_1", out Enemy enemy))
{
Debug.LogWarning("Enemy not found");
return;
}

// Use the enemy...

2. Return Nullable Types

// Instead of throwing exceptions
public Item? GetItem(int id)
{
if (itemDatabase.ContainsKey(id))
{
return itemDatabase[id];
}

return null;
}

// Usage
Item? item = GetItem(123);
if (item.HasValue)
{
// Use the item...
}
else
{
Debug.LogWarning("Item not found");
}

3. Result Pattern

public struct Result<T>
{
public bool Success { get; }
public T Value { get; }
public string ErrorMessage { get; }

private Result(bool success, T value, string errorMessage)
{
Success = success;
Value = value;
ErrorMessage = errorMessage;
}

public static Result<T> Ok(T value) => new Result<T>(true, value, null);
public static Result<T> Fail(string errorMessage) => new Result<T>(false, default, errorMessage);
}

// Usage
public Result<PlayerData> LoadPlayerData(string playerId)
{
string path = Path.Combine(Application.persistentDataPath, $"{playerId}.json");

if (!File.Exists(path))
{
return Result<PlayerData>.Fail($"Player data file not found for ID: {playerId}");
}

try
{
string json = File.ReadAllText(path);
PlayerData data = JsonUtility.FromJson<PlayerData>(json);
return Result<PlayerData>.Ok(data);
}
catch (Exception e)
{
return Result<PlayerData>.Fail($"Error loading player data: {e.Message}");
}
}

// Client code
Result<PlayerData> result = LoadPlayerData("player123");
if (result.Success)
{
PlayerData data = result.Value;
// Use the data...
}
else
{
Debug.LogWarning(result.ErrorMessage);
// Handle the error...
}
Unity Relevance

In Unity, it's particularly important to avoid throwing exceptions in performance-critical code paths like Update(), FixedUpdate(), or rendering code. For these scenarios, consider using the alternative patterns described above.

Unity-Specific Considerations

1. Exception Handling in MonoBehaviour Methods

Unity's component lifecycle methods (like Update, Start, etc.) have built-in exception handling. If an exception occurs in these methods, Unity will:

  1. Log the exception to the console
  2. Disable the component that threw the exception
  3. Continue running the game

This behavior can make debugging difficult, as your game might continue running in an inconsistent state. Consider adding your own try-catch blocks in critical methods:

void Update()
{
try
{
// Update game logic
UpdatePlayerMovement();
UpdateEnemies();
CheckCollisions();
}
catch (Exception e)
{
Debug.LogError($"Critical error in Update: {e.Message}\n{e.StackTrace}");

// Pause the game instead of letting it continue in a broken state
Time.timeScale = 0;

// Show an error UI
errorPanel.SetActive(true);
errorText.text = "An error occurred. Please restart the game.";
}
}

2. Coroutines and Exceptions

Exceptions in coroutines can be particularly tricky, as they don't propagate normally:

// This won't catch exceptions from the coroutine
try
{
StartCoroutine(LoadLevelCoroutine());
}
catch (Exception e)
{
// This catch block will never execute for exceptions inside the coroutine
Debug.LogError(e);
}

Instead, handle exceptions inside the coroutine:

private IEnumerator LoadLevelCoroutine()
{
try
{
// Coroutine logic that might throw exceptions
yield return StartCoroutine(LoadAssets());
yield return StartCoroutine(SpawnEntities());
yield return StartCoroutine(InitializeAI());
}
catch (Exception e)
{
Debug.LogError($"Error in LoadLevelCoroutine: {e.Message}");
onLevelLoadFailed?.Invoke(e.Message);
yield break;
}

onLevelLoadCompleted?.Invoke();
}

Conclusion

The throw statement is a powerful tool for signaling and handling error conditions in your code. By using it effectively, you can:

  • Communicate clear error information
  • Ensure your methods are used correctly
  • Fail fast when invalid conditions are detected
  • Create robust, self-validating APIs

Remember these key principles:

  1. Throw exceptions for exceptional conditions, not normal control flow
  2. Include detailed information in your exception messages
  3. Create custom exception types for domain-specific errors
  4. Consider performance implications in critical code paths
  5. Handle exceptions at the appropriate level of abstraction

In the next section, we'll explore common .NET exception types and when to use them in your game code.