Skip to main content

7.7 - Creating Custom Exceptions

While the .NET framework provides many built-in exception types, game development often involves domain-specific error scenarios that aren't covered by standard exceptions. In these cases, creating custom exception classes can make your error handling more expressive, specific, and useful.

Why Create Custom Exceptions?​

Custom exceptions offer several benefits:

  1. Domain-specific error information: Include game-specific data in your exceptions
  2. Clearer error categorization: Group related errors under a common base class
  3. More precise exception handling: Catch only the specific exceptions you're interested in
  4. Self-documenting code: Make your error conditions explicit in your API
  5. Better debugging: Provide more context about what went wrong

Custom Exception Basics​

All custom exceptions should:

  1. Derive from System.Exception or a more specific exception class
  2. Include standard constructors
  3. Follow the naming convention of ending with "Exception"
  4. Be serializable (for cross-AppDomain scenarios)

Here's a basic template:

using System;
using System.Runtime.Serialization;

[Serializable]
public class GameException : Exception
{
// Default constructor
public GameException() : base() { }

// Constructor with message
public GameException(string message) : base(message) { }

// Constructor with message and inner exception
public GameException(string message, Exception innerException)
: base(message, innerException) { }

// Constructor for serialization
protected GameException(SerializationInfo info, StreamingContext context)
: base(info, context) { }
}

Designing a Custom Exception Hierarchy​

For a game, you might want to create a hierarchy of exceptions:

// Base exception for all game-related errors
[Serializable]
public class GameException : Exception
{
public GameException() : base() { }
public GameException(string message) : base(message) { }
public GameException(string message, Exception innerException)
: base(message, innerException) { }
protected GameException(SerializationInfo info, StreamingContext context)
: base(info, context) { }
}

// Category-specific exceptions
[Serializable]
public class GameplayException : GameException
{
public GameplayException() : base() { }
public GameplayException(string message) : base(message) { }
public GameplayException(string message, Exception innerException)
: base(message, innerException) { }
protected GameplayException(SerializationInfo info, StreamingContext context)
: base(info, context) { }
}

[Serializable]
public class ResourceException : GameException
{
public ResourceException() : base() { }
public ResourceException(string message) : base(message) { }
public ResourceException(string message, Exception innerException)
: base(message, innerException) { }
protected ResourceException(SerializationInfo info, StreamingContext context)
: base(info, context) { }
}

// Specific exceptions
[Serializable]
public class EnemyNotFoundException : GameplayException
{
public string EnemyId { get; }

public EnemyNotFoundException(string enemyId)
: base($"Enemy with ID '{enemyId}' not found")
{
EnemyId = enemyId;
}

public EnemyNotFoundException(string enemyId, string message)
: base(message)
{
EnemyId = enemyId;
}

public EnemyNotFoundException(string enemyId, string message, Exception innerException)
: base(message, innerException)
{
EnemyId = enemyId;
}

protected EnemyNotFoundException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
EnemyId = info.GetString(nameof(EnemyId));
}

public override void GetObjectData(SerializationInfo info, StreamingContext context)
{
base.GetObjectData(info, context);
info.AddValue(nameof(EnemyId), EnemyId);
}
}

This hierarchy allows you to:

  1. Catch all game exceptions with catch (GameException)
  2. Catch category-specific exceptions with catch (GameplayException) or catch (ResourceException)
  3. Catch very specific exceptions with catch (EnemyNotFoundException)

Adding Custom Properties​

Custom exceptions can include additional properties that provide context about the error:

[Serializable]
public class InventoryFullException : GameplayException
{
public Item RejectedItem { get; }
public int CurrentCapacity { get; }
public int MaxCapacity { get; }

public InventoryFullException(Item rejectedItem, int currentCapacity, int maxCapacity)
: base($"Cannot add {rejectedItem.Name} to inventory: {currentCapacity}/{maxCapacity} slots used")
{
RejectedItem = rejectedItem;
CurrentCapacity = currentCapacity;
MaxCapacity = maxCapacity;
}

// Additional constructors...

protected InventoryFullException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
// In a real implementation, you'd need to handle the serialization of Item
// This is simplified for the example
CurrentCapacity = info.GetInt32(nameof(CurrentCapacity));
MaxCapacity = info.GetInt32(nameof(MaxCapacity));
}

public override void GetObjectData(SerializationInfo info, StreamingContext context)
{
base.GetObjectData(info, context);
// Serialize the custom properties
info.AddValue(nameof(CurrentCapacity), CurrentCapacity);
info.AddValue(nameof(MaxCapacity), MaxCapacity);
// In a real implementation, you'd need to handle the serialization of Item
}
}

These properties allow exception handlers to make more informed decisions:

try
{
inventory.AddItem(newItem);
}
catch (InventoryFullException e)
{
Debug.LogWarning($"Inventory full: {e.CurrentCapacity}/{e.MaxCapacity}");

// Use the exception properties to handle the error
if (e.RejectedItem.IsStackable)
{
// Try to stack with existing items
inventory.TryStackItem(e.RejectedItem);
}
else if (e.RejectedItem.IsValuable)
{
// Show a prompt asking if the player wants to discard something else
uiManager.ShowInventoryFullPrompt(e.RejectedItem);
}
else
{
// Just inform the player
uiManager.ShowMessage($"Cannot pick up {e.RejectedItem.Name}: inventory full");
}
}

Game-Specific Exception Examples​

Let's explore some examples of custom exceptions for different game systems:

1. Quest System Exceptions​

[Serializable]
public class QuestException : GameException
{
public string QuestId { get; }

public QuestException(string questId, string message)
: base(message)
{
QuestId = questId;
}

// Additional constructors...
}

[Serializable]
public class QuestNotFoundException : QuestException
{
public QuestNotFoundException(string questId)
: base(questId, $"Quest '{questId}' not found")
{
}

// Additional constructors...
}

[Serializable]
public class QuestRequirementsNotMetException : QuestException
{
public List<string> MissingRequirements { get; }

public QuestRequirementsNotMetException(string questId, List<string> missingRequirements)
: base(questId, $"Requirements not met for quest '{questId}': {string.Join(", ", missingRequirements)}")
{
MissingRequirements = new List<string>(missingRequirements);
}

// Additional constructors...
}

[Serializable]
public class QuestAlreadyCompletedException : QuestException
{
public DateTime CompletionTime { get; }

public QuestAlreadyCompletedException(string questId, DateTime completionTime)
: base(questId, $"Quest '{questId}' was already completed at {completionTime}")
{
CompletionTime = completionTime;
}

// Additional constructors...
}

2. Combat System Exceptions​

[Serializable]
public class CombatException : GameException
{
public CombatException() : base() { }
public CombatException(string message) : base(message) { }
// Additional constructors...
}

[Serializable]
public class OutOfRangeException : CombatException
{
public float CurrentDistance { get; }
public float MaxRange { get; }

public OutOfRangeException(float currentDistance, float maxRange)
: base($"Target is out of range: {currentDistance:F1} units (max range: {maxRange:F1})")
{
CurrentDistance = currentDistance;
MaxRange = maxRange;
}

// Additional constructors...
}

[Serializable]
public class InsufficientResourceException : CombatException
{
public string ResourceType { get; }
public float CurrentAmount { get; }
public float RequiredAmount { get; }

public InsufficientResourceException(string resourceType, float currentAmount, float requiredAmount)
: base($"Insufficient {resourceType}: have {currentAmount}, need {requiredAmount}")
{
ResourceType = resourceType;
CurrentAmount = currentAmount;
RequiredAmount = requiredAmount;
}

// Additional constructors...
}

[Serializable]
public class AbilityOnCooldownException : CombatException
{
public string AbilityName { get; }
public float RemainingCooldown { get; }

public AbilityOnCooldownException(string abilityName, float remainingCooldown)
: base($"Ability '{abilityName}' is on cooldown for {remainingCooldown:F1} more seconds")
{
AbilityName = abilityName;
RemainingCooldown = remainingCooldown;
}

// Additional constructors...
}

3. Save System Exceptions​

[Serializable]
public class SaveSystemException : GameException
{
public SaveSystemException() : base() { }
public SaveSystemException(string message) : base(message) { }
// Additional constructors...
}

[Serializable]
public class SaveCorruptedException : SaveSystemException
{
public string SaveName { get; }
public string BackupPath { get; }

public SaveCorruptedException(string saveName, string backupPath = null)
: base($"Save file '{saveName}' is corrupted")
{
SaveName = saveName;
BackupPath = backupPath;
}

// Additional constructors...
}

[Serializable]
public class SaveVersionMismatchException : SaveSystemException
{
public string SaveVersion { get; }
public string GameVersion { get; }
public bool IsCompatible { get; }

public SaveVersionMismatchException(string saveVersion, string gameVersion, bool isCompatible)
: base($"Save version mismatch: save is version {saveVersion}, game is version {gameVersion}")
{
SaveVersion = saveVersion;
GameVersion = gameVersion;
IsCompatible = isCompatible;
}

// Additional constructors...
}

[Serializable]
public class SaveSlotInUseException : SaveSystemException
{
public string SlotName { get; }
public DateTime LastModified { get; }

public SaveSlotInUseException(string slotName, DateTime lastModified)
: base($"Save slot '{slotName}' is already in use (last modified: {lastModified})")
{
SlotName = slotName;
LastModified = lastModified;
}

// Additional constructors...
}

Using Custom Exceptions​

Here's how you might use these custom exceptions in your game code:

1. Quest System Example​

public class QuestManager : MonoBehaviour
{
private Dictionary<string, Quest> quests = new Dictionary<string, Quest>();
private List<string> completedQuests = new List<string>();

public void StartQuest(string questId)
{
try
{
// Check if the quest exists
if (!quests.TryGetValue(questId, out Quest quest))
{
throw new QuestNotFoundException(questId);
}

// Check if the quest is already completed
if (completedQuests.Contains(questId))
{
throw new QuestAlreadyCompletedException(
questId,
quest.CompletionTime ?? DateTime.Now);
}

// Check if requirements are met
List<string> missingRequirements = quest.GetMissingRequirements(playerData);
if (missingRequirements.Count > 0)
{
throw new QuestRequirementsNotMetException(questId, missingRequirements);
}

// Start the quest
quest.Start();
Debug.Log($"Started quest: {quest.Title}");
}
catch (QuestNotFoundException e)
{
Debug.LogError(e.Message);
uiManager.ShowError("Quest not found");
}
catch (QuestAlreadyCompletedException e)
{
Debug.LogWarning(e.Message);
uiManager.ShowMessage($"You've already completed this quest on {e.CompletionTime.ToShortDateString()}");
}
catch (QuestRequirementsNotMetException e)
{
Debug.LogWarning(e.Message);

// Show the player what requirements they're missing
uiManager.ShowQuestRequirements(e.QuestId, e.MissingRequirements);
}
catch (Exception e)
{
Debug.LogError($"Unexpected error starting quest: {e.Message}");
uiManager.ShowError("An error occurred while starting the quest");
}
}
}

2. Combat System Example​

public class CombatSystem : MonoBehaviour
{
public void UseAbility(Character caster, Character target, Ability ability)
{
try
{
// Check range
float distance = Vector3.Distance(caster.transform.position, target.transform.position);
if (distance > ability.Range)
{
throw new OutOfRangeException(distance, ability.Range);
}

// Check resources
if (caster.Mana < ability.ManaCost)
{
throw new InsufficientResourceException("mana", caster.Mana, ability.ManaCost);
}

if (caster.Stamina < ability.StaminaCost)
{
throw new InsufficientResourceException("stamina", caster.Stamina, ability.StaminaCost);
}

// Check cooldown
float remainingCooldown = caster.GetRemainingCooldown(ability);
if (remainingCooldown > 0)
{
throw new AbilityOnCooldownException(ability.Name, remainingCooldown);
}

// Use the ability
ability.Execute(caster, target);

// Apply costs
caster.Mana -= ability.ManaCost;
caster.Stamina -= ability.StaminaCost;

// Start cooldown
caster.StartCooldown(ability);
}
catch (OutOfRangeException e)
{
Debug.LogWarning(e.Message);

// Show a visual indicator of the range
visualEffects.ShowRangeIndicator(caster.transform.position, e.MaxRange);

// Tell the player
uiManager.ShowMessage("Target is out of range");
}
catch (InsufficientResourceException e)
{
Debug.LogWarning(e.Message);

// Flash the resource that's insufficient
uiManager.FlashResourceBar(e.ResourceType);

// Tell the player
uiManager.ShowMessage($"Not enough {e.ResourceType}");
}
catch (AbilityOnCooldownException e)
{
Debug.LogWarning(e.Message);

// Flash the ability icon
uiManager.FlashAbilityIcon(e.AbilityName);

// Tell the player
uiManager.ShowMessage($"{e.AbilityName} is on cooldown ({e.RemainingCooldown:F1}s)");
}
catch (Exception e)
{
Debug.LogError($"Unexpected error using ability: {e.Message}");
uiManager.ShowError("An error occurred while using the ability");
}
}
}

3. Save System Example​

public class SaveSystem : MonoBehaviour
{
public void LoadGame(string slotName)
{
try
{
string path = Path.Combine(Application.persistentDataPath, $"{slotName}.sav");

// Check if the save exists
if (!File.Exists(path))
{
throw new FileNotFoundException($"Save file not found: {slotName}");
}

// Read the save file
string json = File.ReadAllText(path);

try
{
// Try to parse the save data
SaveData saveData = JsonUtility.FromJson<SaveData>(json);

// Check the version
if (saveData.GameVersion != Application.version)
{
bool isCompatible = IsVersionCompatible(saveData.GameVersion, Application.version);
throw new SaveVersionMismatchException(
saveData.GameVersion,
Application.version,
isCompatible);
}

// Load the game data
LoadGameData(saveData);

Debug.Log($"Game loaded from slot: {slotName}");
}
catch (ArgumentException)
{
// Create a backup of the corrupted save
string backupPath = path + ".bak";
File.Copy(path, backupPath, true);

throw new SaveCorruptedException(slotName, backupPath);
}
}
catch (FileNotFoundException e)
{
Debug.LogWarning(e.Message);
uiManager.ShowMessage($"No save found in slot: {slotName}");
}
catch (SaveCorruptedException e)
{
Debug.LogError(e.Message);

if (e.BackupPath != null)
{
Debug.Log($"Corrupted save backed up to: {e.BackupPath}");
}

uiManager.ShowError($"Save file is corrupted: {e.SaveName}");
}
catch (SaveVersionMismatchException e)
{
Debug.LogWarning(e.Message);

if (e.IsCompatible)
{
// We can still load the save, but warn the player
uiManager.ShowConfirmationDialog(
$"This save was created with version {e.SaveVersion}. Loading it in version {e.GameVersion} may cause issues. Continue?",
() => LoadGameWithVersionMismatch(slotName),
null);
}
else
{
// The save is incompatible
uiManager.ShowError($"This save is from version {e.SaveVersion} and cannot be loaded in version {e.GameVersion}");
}
}
catch (Exception e)
{
Debug.LogError($"Unexpected error loading game: {e.Message}");
uiManager.ShowError("An error occurred while loading the game");
}
}

private bool IsVersionCompatible(string saveVersion, string gameVersion)
{
// Implement version compatibility logic
// For example, major version must match, minor version can be different
return saveVersion.Split('.')[0] == gameVersion.Split('.')[0];
}

private void LoadGameWithVersionMismatch(string slotName)
{
// Implement special loading logic for version mismatches
Debug.Log($"Loading save with version mismatch: {slotName}");
// ...
}
}

Best Practices for Custom Exceptions​

1. Be Specific​

Create exception types that are specific enough to be useful, but not so specific that you end up with hundreds of nearly identical classes.

// Too general
throw new GameException("Quest requirements not met");

// Good balance
throw new QuestRequirementsNotMetException(questId, missingRequirements);

// Too specific (probably unnecessary)
throw new QuestLevelRequirementNotMetException(questId, playerLevel, requiredLevel);

2. Include Useful Information​

Make sure your exceptions include all the information needed to understand and potentially recover from the error.

// Minimal information
throw new Exception("Can't use ability");

// Better
throw new AbilityOnCooldownException("Fireball", 3.5f);

3. Use Meaningful Names​

Name your exceptions clearly to indicate what went wrong.

// Unclear
throw new GameplayError("Target too far");

// Clear
throw new TargetOutOfRangeException(currentDistance, maxRange);

4. Document Your Exceptions​

Use XML documentation to describe when and why your methods might throw exceptions.

/// <summary>
/// Starts a quest for the player.
/// </summary>
/// <param name="questId">The ID of the quest to start.</param>
/// <exception cref="QuestNotFoundException">Thrown when the quest ID doesn't exist.</exception>
/// <exception cref="QuestAlreadyCompletedException">Thrown when the quest has already been completed.</exception>
/// <exception cref="QuestRequirementsNotMetException">Thrown when the player doesn't meet the quest requirements.</exception>
public void StartQuest(string questId)
{
// Implementation...
}

5. Consider Performance​

Creating and throwing exceptions has a performance cost. For performance-critical code, consider alternatives:

// Using exceptions (slower but cleaner for non-critical paths)
public Item GetItem(string itemId)
{
if (!itemDictionary.TryGetValue(itemId, out Item item))
{
throw new ItemNotFoundException(itemId);
}

return item;
}

// Using return values (faster for performance-critical paths)
public bool TryGetItem(string itemId, out Item item)
{
return itemDictionary.TryGetValue(itemId, out item);
}

6. Don't Swallow Exceptions​

When catching exceptions, either handle them properly or rethrow them.

// Bad: Swallowing the exception
try
{
LoadGameData();
}
catch (Exception)
{
// Empty catch block
}

// Good: Handling the exception
try
{
LoadGameData();
}
catch (Exception e)
{
Debug.LogError($"Failed to load game data: {e.Message}");
ShowErrorScreen("Failed to load game data");
}

// Also good: Rethrowing the exception
try
{
LoadGameData();
}
catch (Exception e)
{
Debug.LogError($"Failed to load game data: {e.Message}");
throw; // Rethrow to let higher levels handle it
}

Unity-Specific Considerations​

1. MonoBehaviour and Exceptions​

Remember that exceptions thrown in Unity's lifecycle methods (like Update, Start, etc.) will disable the component. Consider adding try-catch blocks in these methods:

void Update()
{
try
{
// Update logic that might throw exceptions
UpdateGameState();
ProcessInput();
UpdateUI();
}
catch (Exception e)
{
Debug.LogError($"Error in Update: {e.Message}");

// Handle the error without disabling the component
RecoverFromError();
}
}

2. Editor Scripts and Exceptions​

When writing editor scripts, be especially careful with exceptions, as they can disrupt the Unity Editor:

[CustomEditor(typeof(QuestManager))]
public class QuestManagerEditor : Editor
{
public override void OnInspectorGUI()
{
try
{
// Custom inspector code that might throw exceptions
DrawDefaultInspector();
DrawQuestList();
}
catch (Exception e)
{
EditorGUILayout.HelpBox($"Error in inspector: {e.Message}", MessageType.Error);

// Log the full exception for debugging
Debug.LogException(e);
}
}
}

3. Serialization Considerations​

Unity's serialization system doesn't support all types. When creating custom exceptions with properties, be aware of what can be serialized:

[Serializable]
public class ItemException : GameException
{
// Unity can serialize simple types like string, int, float, etc.
public string ItemId { get; }

// Unity cannot serialize complex types like Item directly
// Consider storing just the necessary data instead
// public Item Item { get; } // Problematic for serialization

public string ItemName { get; }
public int ItemLevel { get; }

public ItemException(string itemId, string itemName, int itemLevel, string message)
: base(message)
{
ItemId = itemId;
ItemName = itemName;
ItemLevel = itemLevel;
}

// Additional constructors...
}

Conclusion​

Creating custom exceptions allows you to build a more expressive, robust error handling system tailored to your game's specific needs. By following best practices and organizing your exceptions into a logical hierarchy, you can make your code more maintainable and your error handling more effective.

Key takeaways:

  • Create a hierarchy of game-specific exceptions
  • Include relevant information in your exception classes
  • Use meaningful names and good documentation
  • Consider performance implications
  • Handle exceptions appropriately based on their type and severity

With these tools and techniques, you can build games that gracefully handle errors, providing a better experience for your players and making your life as a developer easier.