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.