7.6 - Common .NET Exception Types
The .NET framework provides a rich hierarchy of exception types, each designed for specific error scenarios. Understanding these common exception types will help you:
- Recognize what went wrong when an exception occurs
- Choose the appropriate exception type to throw
- Handle different error scenarios appropriately
- Write more robust and maintainable code
The Exception Hierarchy
All exceptions in C# derive from the System.Exception
class. The framework organizes exceptions in a hierarchy:
System.Exception
├── System.SystemException
│ ├── System.ArgumentException
│ │ ├── System.ArgumentNullException
│ │ └── System.ArgumentOutOfRangeException
│ ├── System.NullReferenceException
│ ├── System.InvalidOperationException
│ ├── System.IndexOutOfRangeException
│ ├── System.IO.IOException
│ │ ├── System.IO.FileNotFoundException
│ │ └── System.IO.DirectoryNotFoundException
│ ├── System.DivideByZeroException
│ ├── System.FormatException
│ ├── System.OverflowException
│ └── ...
└── System.ApplicationException
└── [Custom application exceptions]
Let's explore the most common exception types you'll encounter in game development.
System.Exception
System.Exception
is the base class for all exceptions. It provides properties like:
- Message: A human-readable description of the error
- StackTrace: A list of method calls that led to the exception
- InnerException: The exception that caused the current exception (if applicable)
- Source: The name of the application or object that caused the error
You rarely throw System.Exception
directly; instead, use a more specific derived type.
Argument Validation Exceptions
These exceptions indicate that a method received invalid arguments.
ArgumentException
Use when an argument provided to a method is invalid for any reason.
public void SetDifficulty(string difficultyName)
{
string[] validDifficulties = { "Easy", "Normal", "Hard", "Nightmare" };
if (!validDifficulties.Contains(difficultyName))
{
string validOptions = string.Join(", ", validDifficulties);
throw new ArgumentException(
$"Invalid difficulty '{difficultyName}'. Valid options are: {validOptions}",
nameof(difficultyName));
}
// Set the difficulty...
}
ArgumentNullException
Use when an argument is null when it shouldn't be.
public void AddItem(Inventory inventory, Item item)
{
if (inventory == null)
{
throw new ArgumentNullException(nameof(inventory), "Inventory cannot be null");
}
if (item == null)
{
throw new ArgumentNullException(nameof(item), "Item cannot be null");
}
inventory.AddItem(item);
}
ArgumentOutOfRangeException
Use when an argument is outside the valid range of values.
public void SetVolume(float volume)
{
if (volume < 0f || volume > 1f)
{
throw new ArgumentOutOfRangeException(
nameof(volume),
volume,
"Volume must be between 0 and 1");
}
audioSource.volume = volume;
}
Operational Exceptions
These exceptions indicate problems with the operation being performed.
InvalidOperationException
Use when a method call is invalid for the object's current state.
public class GameManager
{
private bool isGameStarted = false;
public void StartGame()
{
if (isGameStarted)
{
throw new InvalidOperationException("Game is already started");
}
isGameStarted = true;
// Initialize game...
}
public void EndGame()
{
if (!isGameStarted)
{
throw new InvalidOperationException("Cannot end game that hasn't been started");
}
isGameStarted = false;
// Clean up game...
}
}
NotSupportedException
Use when an operation is not supported.
public class ReadOnlyInventory : IInventory
{
private List<Item> items;
public ReadOnlyInventory(List<Item> initialItems)
{
items = new List<Item>(initialItems); // Create a copy
}
public void AddItem(Item item)
{
throw new NotSupportedException("Cannot add items to a read-only inventory");
}
public void RemoveItem(Item item)
{
throw new NotSupportedException("Cannot remove items from a read-only inventory");
}
public List<Item> GetItems()
{
return new List<Item>(items); // Return a copy to maintain read-only nature
}
}
NotImplementedException
Use when a method or property is not yet implemented.
public class EnemyBase
{
public virtual void SpecialAttack()
{
// Base class doesn't implement this
throw new NotImplementedException("Derived classes must implement SpecialAttack");
}
}
public class Goblin : EnemyBase
{
public override void SpecialAttack()
{
// Implementation for Goblin
Debug.Log("Goblin performs a sneak attack!");
}
}
public class Dragon : EnemyBase
{
public override void SpecialAttack()
{
// Implementation for Dragon
Debug.Log("Dragon breathes fire!");
}
}
ObjectDisposedException
Use when an operation is performed on an object that has been disposed.
public class GameLogger : IDisposable
{
private StreamWriter logWriter;
private bool disposed = false;
public GameLogger(string logPath)
{
logWriter = new StreamWriter(logPath, true);
}
public void LogMessage(string message)
{
if (disposed)
{
throw new ObjectDisposedException(nameof(GameLogger), "Cannot write to a disposed logger");
}
logWriter.WriteLine($"[{DateTime.Now}] {message}");
logWriter.Flush();
}
public void Dispose()
{
if (!disposed)
{
logWriter.Dispose();
disposed = true;
}
}
}
Runtime Exceptions
These exceptions indicate problems that occur during program execution.
NullReferenceException
Occurs when you try to access a member of a null object. You typically don't throw this explicitly; instead, check for null before accessing members.
// This can throw NullReferenceException if player is null
public void DamagePlayer(Player player, int amount)
{
// Bad: This might throw NullReferenceException
player.Health -= amount;
// Good: Check for null first
if (player == null)
{
throw new ArgumentNullException(nameof(player), "Player cannot be null");
}
player.Health -= amount;
}
IndexOutOfRangeException
Occurs when you try to access an array element with an invalid index. Like NullReferenceException
, you typically don't throw this explicitly.
// This can throw IndexOutOfRangeException if index is invalid
public Item GetInventoryItem(int index)
{
// Bad: This might throw IndexOutOfRangeException
return inventory[index];
// Good: Check the index first
if (index < 0 || index >= inventory.Count)
{
throw new ArgumentOutOfRangeException(
nameof(index),
$"Index must be between 0 and {inventory.Count - 1}");
}
return inventory[index];
}
DivideByZeroException
Occurs when you try to divide an integer by zero. Note that floating-point division by zero doesn't throw an exception; it returns Infinity
or NaN
.
public int CalculateDamage(int baseDamage, int armorValue)
{
// This will throw DivideByZeroException if armorValue is 0
return baseDamage / armorValue;
// Better approach: Check for zero
if (armorValue == 0)
{
return baseDamage; // Or some other appropriate value
}
return baseDamage / armorValue;
}
OverflowException
Occurs when an arithmetic operation overflows. This only happens in checked contexts.
public int AddExperience(int currentExp, int gainedExp)
{
// This will throw OverflowException if the sum exceeds int.MaxValue
checked
{
return currentExp + gainedExp;
}
// Better approach: Check for overflow
if (int.MaxValue - currentExp < gainedExp)
{
return int.MaxValue; // Cap at maximum value
}
return currentExp + gainedExp;
}
IO Exceptions
These exceptions indicate problems with input/output operations.
IOException
The base class for exceptions thrown by IO operations.
public void SaveScreenshot(Texture2D screenshot, string filename)
{
string path = Path.Combine(Application.persistentDataPath, filename);
try
{
File.WriteAllBytes(path, screenshot.EncodeToPNG());
}
catch (IOException e)
{
Debug.LogError($"Failed to save screenshot: {e.Message}");
throw;
}
}
FileNotFoundException
Occurs when you try to access a file that doesn't exist.
public GameData LoadGame(string saveName)
{
string path = Path.Combine(Application.persistentDataPath, $"{saveName}.sav");
if (!File.Exists(path))
{
throw new FileNotFoundException($"Save file '{saveName}' not found", path);
}
// Load the file...
}
DirectoryNotFoundException
Occurs when you try to access a directory that doesn't exist.
public void SaveReplay(GameReplay replay, string replayName)
{
string replayDir = Path.Combine(Application.persistentDataPath, "Replays");
if (!Directory.Exists(replayDir))
{
try
{
Directory.CreateDirectory(replayDir);
}
catch (IOException e)
{
throw new DirectoryNotFoundException(
$"Replay directory could not be created: {e.Message}", e);
}
}
string path = Path.Combine(replayDir, $"{replayName}.replay");
// Save the replay...
}
UnauthorizedAccessException
Occurs when you don't have permission to access a file or directory.
public void ExportGameData(string exportPath)
{
try
{
// Try to write to the specified path
File.WriteAllText(exportPath, JsonUtility.ToJson(gameData));
}
catch (UnauthorizedAccessException e)
{
Debug.LogError($"Permission denied: {e.Message}");
// Fall back to a location we know we can write to
string fallbackPath = Path.Combine(Application.persistentDataPath, "export.json");
Debug.Log($"Falling back to: {fallbackPath}");
File.WriteAllText(fallbackPath, JsonUtility.ToJson(gameData));
}
}
Format and Parsing Exceptions
These exceptions indicate problems with data formats and parsing.
FormatException
Occurs when the format of an argument doesn't meet the parameter specifications.
public void ProcessPlayerInput(string input)
{
// Example: "MOVE 10 20" to move to coordinates (10, 20)
string[] parts = input.Split(' ');
if (parts.Length < 3)
{
throw new FormatException("Input must have at least 3 parts: command x y");
}
string command = parts[0].ToUpper();
try
{
int x = int.Parse(parts[1]);
int y = int.Parse(parts[2]);
// Process the command...
if (command == "MOVE")
{
MovePlayer(x, y);
}
}
catch (FormatException)
{
throw new FormatException("Coordinates must be valid numbers");
}
}
JsonException
Occurs when JSON parsing fails (from System.Text.Json
namespace).
public PlayerData LoadPlayerData(string json)
{
try
{
return JsonUtility.FromJson<PlayerData>(json);
}
catch (Exception e)
{
// Unity's JsonUtility doesn't throw JsonException, but we can wrap the error
throw new InvalidOperationException($"Failed to parse player data: {e.Message}", e);
}
}
Collection Exceptions
These exceptions relate to collection operations.
KeyNotFoundException
Occurs when you try to access a dictionary key that doesn't exist.
public Enemy GetEnemyById(string id)
{
// This will throw KeyNotFoundException if the key doesn't exist
return enemyDictionary[id];
// Better approach: Check if the key exists
if (enemyDictionary.TryGetValue(id, out Enemy enemy))
{
return enemy;
}
throw new KeyNotFoundException($"Enemy with ID '{id}' not found");
}
InvalidCastException
Occurs when you try to cast an object to an incompatible type.
public T GetComponent<T>(GameObject gameObject) where T : Component
{
Component component = gameObject.GetComponent(typeof(T));
if (component == null)
{
throw new InvalidOperationException($"GameObject does not have a {typeof(T).Name} component");
}
// This could throw InvalidCastException if the component is not of type T
return (T)component;
// Better approach: Use Unity's GetComponent<T>() which handles this safely
// return gameObject.GetComponent<T>();
}
Threading and Task Exceptions
These exceptions relate to asynchronous operations and threading.
OperationCanceledException
Occurs when an operation is canceled via a CancellationToken
.
public async Task<LevelData> LoadLevelAsync(string levelName, CancellationToken cancellationToken)
{
try
{
// Simulate a long-running operation
for (int i = 0; i < 10; i++)
{
// Check if cancellation was requested
cancellationToken.ThrowIfCancellationRequested();
// Do some work...
await Task.Delay(500, cancellationToken);
}
return new LevelData(levelName);
}
catch (OperationCanceledException)
{
Debug.Log("Level loading was canceled");
throw; // Rethrow to let the caller know it was canceled
}
}
// Usage
public async void StartLevel(string levelName)
{
loadingScreen.SetActive(true);
try
{
using (var cts = new CancellationTokenSource())
{
// Allow cancellation after 10 seconds
cts.CancelAfter(10000);
LevelData data = await LoadLevelAsync(levelName, cts.Token);
InitializeLevel(data);
}
}
catch (OperationCanceledException)
{
Debug.LogWarning("Level loading timed out");
ShowErrorMessage("Level loading took too long. Please try again.");
}
finally
{
loadingScreen.SetActive(false);
}
}
AggregateException
Wraps multiple exceptions that occurred during parallel operations.
public async Task LoadGameAssetsAsync()
{
Task<Texture2D> texturesTask = LoadTexturesAsync();
Task<AudioClip[]> audioTask = LoadAudioAsync();
Task<GameObject[]> prefabsTask = LoadPrefabsAsync();
try
{
// Wait for all tasks to complete
await Task.WhenAll(texturesTask, audioTask, prefabsTask);
// If we get here, all tasks completed successfully
Debug.Log("All assets loaded successfully");
}
catch (AggregateException ae)
{
// This catches exceptions from any of the tasks
foreach (var e in ae.InnerExceptions)
{
Debug.LogError($"Asset loading error: {e.Message}");
}
throw new InvalidOperationException("Failed to load one or more asset types", ae);
}
}
Unity-Specific Exceptions
Unity adds some of its own exception types to the mix.
MissingReferenceException
Occurs when you try to access a Unity object that has been destroyed.
public void DamageEnemy(Enemy enemy, int damage)
{
// This will throw MissingReferenceException if the enemy has been destroyed
if (enemy == null)
{
Debug.LogWarning("Attempted to damage a null enemy reference");
return;
}
// Unity's null check doesn't catch destroyed objects
// Use this pattern instead:
if (!enemy || enemy == null)
{
Debug.LogWarning("Attempted to damage a destroyed enemy");
return;
}
enemy.TakeDamage(damage);
}
MissingComponentException
Occurs when you try to access a component that doesn't exist on a GameObject.
public void ActivateWeapon(GameObject weaponObject)
{
// This will throw MissingComponentException if the component doesn't exist
Weapon weapon = weaponObject.GetComponent<Weapon>();
weapon.Activate();
// Better approach: Check if the component exists
weapon = weaponObject.GetComponent<Weapon>();
if (weapon == null)
{
throw new InvalidOperationException($"GameObject '{weaponObject.name}' does not have a Weapon component");
}
weapon.Activate();
}
Creating Custom Exception Types
While the .NET framework provides many exception types, you might need to create custom exceptions for domain-specific errors in your game.
When to Create Custom Exceptions
Create custom exceptions when:
- You need to include domain-specific information
- You want to enable specific exception handling
- The built-in exceptions don't accurately describe the error
- You want to categorize related errors
Custom Exception Example
// 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 for inventory-related errors
public class InventoryException : GameException
{
public InventoryException(string message) : base(message) { }
public InventoryException(string message, Exception innerException)
: base(message, innerException) { }
}
// More specific inventory exceptions
public class InventoryFullException : InventoryException
{
public Item ItemThatDidntFit { get; }
public InventoryFullException(Item item)
: base($"Cannot add {item.Name} to inventory: inventory is full")
{
ItemThatDidntFit = item;
}
}
public class ItemNotFoundException : InventoryException
{
public string ItemId { get; }
public ItemNotFoundException(string itemId)
: base($"Item with ID '{itemId}' not found in inventory")
{
ItemId = itemId;
}
}
// Usage
public class Inventory
{
private List<Item> items = new List<Item>();
private int maxCapacity = 20;
public void AddItem(Item item)
{
if (items.Count >= maxCapacity)
{
throw new InventoryFullException(item);
}
items.Add(item);
}
public Item GetItem(string itemId)
{
Item item = items.FirstOrDefault(i => i.Id == itemId);
if (item == null)
{
throw new ItemNotFoundException(itemId);
}
return item;
}
public bool TryAddItem(Item item)
{
try
{
AddItem(item);
return true;
}
catch (InventoryFullException)
{
return false;
}
}
}
Handling Different Exception Types
Different exception types often require different handling strategies:
public void SaveGame(string saveName)
{
string path = Path.Combine(Application.persistentDataPath, $"{saveName}.sav");
try
{
string json = JsonUtility.ToJson(gameState);
File.WriteAllText(path, json);
Debug.Log($"Game saved to {path}");
}
catch (UnauthorizedAccessException)
{
Debug.LogError("Permission denied. Cannot write to save location.");
ShowErrorMessage("Cannot save game: permission denied");
}
catch (IOException e)
{
Debug.LogError($"IO error while saving: {e.Message}");
ShowErrorMessage("Cannot save game: disk error");
}
catch (Exception e)
{
Debug.LogError($"Unexpected error while saving: {e.Message}");
ShowErrorMessage("An unexpected error occurred while saving");
}
}
Exception Filtering with when
C# 6.0 introduced exception filters, which allow you to add conditions to catch blocks:
public void LoadLevel(string levelName)
{
try
{
// Try to load the level
SceneManager.LoadScene(levelName);
}
catch (Exception e) when (e.Message.Contains("not found"))
{
// Handle the specific case where the level doesn't exist
Debug.LogError($"Level '{levelName}' not found");
SceneManager.LoadScene("MainMenu");
}
catch (Exception e) when (IsNetworkError(e))
{
// Handle network-related errors
Debug.LogError($"Network error while loading level: {e.Message}");
RetryWithBackoff();
}
catch (Exception e)
{
// Handle any other exceptions
Debug.LogError($"Error loading level: {e.Message}");
ShowErrorScreen();
}
}
private bool IsNetworkError(Exception e)
{
// Check if this is a network-related exception
return e is System.Net.WebException ||
e is System.Net.Sockets.SocketException ||
e.Message.Contains("network") ||
e.Message.Contains("connection");
}
Unity-Specific Exception Handling Patterns
1. Scene Loading Errors
public void LoadGameLevel(int levelIndex)
{
try
{
// Show loading screen
loadingScreen.SetActive(true);
// Try to load the level
SceneManager.LoadScene(levelIndex);
}
catch (Exception e)
{
Debug.LogError($"Failed to load level {levelIndex}: {e.Message}");
// Fall back to the main menu
try
{
SceneManager.LoadScene("MainMenu");
}
catch
{
// If even that fails, reload the current scene
SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
}
// Show error message
errorPanel.SetActive(true);
errorText.text = "Failed to load level. Returning to main menu.";
}
finally
{
// Hide loading screen (will only happen if LoadScene throws an exception)
loadingScreen.SetActive(false);
}
}
2. Asset Loading Errors
public GameObject SpawnEnemy(string enemyType, Vector3 position)
{
try
{
// Try to load the enemy prefab
GameObject prefab = Resources.Load<GameObject>($"Enemies/{enemyType}");
if (prefab == null)
{
throw new InvalidOperationException($"Enemy prefab '{enemyType}' not found");
}
// Instantiate the enemy
return Instantiate(prefab, position, Quaternion.identity);
}
catch (InvalidOperationException e)
{
Debug.LogWarning(e.Message);
// Fall back to a default enemy type
GameObject fallbackPrefab = Resources.Load<GameObject>("Enemies/Default");
if (fallbackPrefab != null)
{
Debug.Log("Using fallback enemy prefab");
return Instantiate(fallbackPrefab, position, Quaternion.identity);
}
Debug.LogError("No fallback enemy prefab available");
return null;
}
catch (Exception e)
{
Debug.LogError($"Error spawning enemy: {e.Message}");
return null;
}
}
3. Component Access Errors
public void ApplyDamageToTarget(GameObject target, int damage)
{
if (target == null)
{
Debug.LogWarning("Cannot apply damage to null target");
return;
}
try
{
Health healthComponent = target.GetComponent<Health>();
if (healthComponent == null)
{
throw new MissingComponentException($"Target {target.name} has no Health component");
}
healthComponent.TakeDamage(damage);
}
catch (MissingComponentException e)
{
Debug.LogWarning(e.Message);
// Try to find a different damage-receiving component
IDamageable damageable = target.GetComponent<IDamageable>();
if (damageable != null)
{
Debug.Log($"Using IDamageable interface on {target.name}");
damageable.TakeDamage(damage);
}
}
catch (MissingReferenceException)
{
Debug.LogWarning("Target was destroyed before damage could be applied");
}
catch (Exception e)
{
Debug.LogError($"Error applying damage: {e.Message}");
}
}
Conclusion
Understanding common .NET exception types helps you:
- Diagnose problems: Recognize what went wrong based on the exception type
- Communicate clearly: Throw the most appropriate exception type for each error scenario
- Handle errors gracefully: Implement specific handling strategies for different exception types
- Design robust APIs: Create methods that validate inputs and communicate errors effectively
Remember these key principles:
- Use the most specific exception type that accurately describes the error
- Include detailed information in exception messages
- Create custom exception types for domain-specific errors
- Handle different exception types according to their severity and recoverability
In the next section, we'll explore how to create your own custom exception classes for game-specific error scenarios.