Skip to main content

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:

  1. Recognize what went wrong when an exception occurs
  2. Choose the appropriate exception type to throw
  3. Handle different error scenarios appropriately
  4. 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:

  1. You need to include domain-specific information
  2. You want to enable specific exception handling
  3. The built-in exceptions don't accurately describe the error
  4. 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:

  1. Diagnose problems: Recognize what went wrong based on the exception type
  2. Communicate clearly: Throw the most appropriate exception type for each error scenario
  3. Handle errors gracefully: Implement specific handling strategies for different exception types
  4. 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.