Error Handling: Try-Catch and Debugging
Errors are inevitable in game development. Whether it's a missing file, invalid input, or unexpected behavior, your code needs to handle errors gracefully. In C#, you use try-catch blocks to catch and handle exceptions, preventing your game from crashing and providing a better experience for players.
In this chapter, you'll learn how to handle errors properly, debug your code effectively, and create robust game systems that don't break when something goes wrong.
What You'll Learn
- Try-Catch Blocks - Catching and handling exceptions
- Exception Types - Understanding different error types
- Finally Blocks - Cleanup code that always runs
- Debugging Techniques - Finding and fixing bugs
- Unity-Specific Debugging - Using Unity's debugging tools
- Best Practices - Writing error-resistant code
Prerequisites
- Completed Collections: Arrays, Lists, and Dictionaries
- Understanding of functions, classes, and control flow
- Basic familiarity with Unity (optional, but helpful)
Understanding Errors and Exceptions
When something goes wrong in your code, C# throws an exception. An exception is an object that represents an error condition. If not handled, exceptions cause your program to crash.
Common Exceptions:
NullReferenceException- Trying to use a null objectIndexOutOfRangeException- Accessing an invalid array indexArgumentException- Invalid argument passed to a functionFileNotFoundException- File doesn't existDivideByZeroException- Dividing by zero
Real-World Analogy: Think of exceptions like fire alarms. When something goes wrong (fire detected), an alarm goes off (exception thrown). If no one responds (no error handling), the building burns down (program crashes). But if firefighters respond (try-catch), they handle the situation (error handling) and prevent disaster (program continues).
Try-Catch Blocks
A try-catch block allows you to attempt code that might fail and handle errors gracefully.
Basic Syntax
try
{
// Code that might throw an exception
int result = 10 / 0; // This will throw DivideByZeroException
}
catch (Exception ex)
{
// Code to handle the exception
Console.WriteLine($"Error: {ex.Message}");
}
How It Works:
- Code in the
tryblock executes normally - If an exception occurs, execution jumps to the
catchblock - The exception is caught and handled
- Program continues after the catch block
Simple Example
try
{
int[] numbers = { 1, 2, 3 };
int value = numbers[10]; // Index out of range!
}
catch (IndexOutOfRangeException ex)
{
Console.WriteLine($"Invalid index: {ex.Message}");
// Program continues here instead of crashing
}
Catching Specific Exceptions
You can catch specific exception types to handle different errors differently.
Multiple Catch Blocks
try
{
// Code that might throw different exceptions
int result = int.Parse(userInput) / divisor;
}
catch (FormatException ex)
{
// Handle invalid number format
Console.WriteLine("Please enter a valid number.");
}
catch (DivideByZeroException ex)
{
// Handle division by zero
Console.WriteLine("Cannot divide by zero.");
}
catch (Exception ex)
{
// Handle any other exception
Console.WriteLine($"Unexpected error: {ex.Message}");
}
Important: More specific exceptions must come before general ones. Exception catches everything, so it should be last.
Common Exception Types
// Null reference
catch (NullReferenceException ex)
{
Console.WriteLine("Object is null!");
}
// Invalid argument
catch (ArgumentException ex)
{
Console.WriteLine($"Invalid argument: {ex.Message}");
}
// File not found
catch (FileNotFoundException ex)
{
Console.WriteLine($"File not found: {ex.FileName}");
}
// Out of memory
catch (OutOfMemoryException ex)
{
Console.WriteLine("Not enough memory!");
}
Finally Blocks
A finally block contains code that always executes, whether an exception occurs or not. Use it for cleanup code.
Finally Block Syntax
try
{
// Code that might throw an exception
File.Open("data.txt");
}
catch (FileNotFoundException ex)
{
Console.WriteLine("File not found!");
}
finally
{
// This code ALWAYS runs
Console.WriteLine("Cleanup code here");
// Close files, release resources, etc.
}
Use Cases:
- Closing files
- Releasing resources
- Resetting variables
- Logging information
Real-World Example: File Handling
FileStream file = null;
try
{
file = File.Open("savegame.dat", FileMode.Open);
// Read file data
}
catch (FileNotFoundException ex)
{
Console.WriteLine("Save file not found. Starting new game.");
}
catch (IOException ex)
{
Console.WriteLine($"File error: {ex.Message}");
}
finally
{
// Always close the file, even if an error occurred
if (file != null)
{
file.Close();
}
}
Throwing Exceptions
You can throw your own exceptions when something goes wrong in your code.
Throwing Exceptions
public void SetHealth(int health)
{
if (health < 0)
{
throw new ArgumentException("Health cannot be negative!");
}
if (health > 100)
{
throw new ArgumentException("Health cannot exceed 100!");
}
this.health = health;
}
Custom Exception Messages
if (player == null)
{
throw new NullReferenceException("Player object is null!");
}
if (enemyCount < 0)
{
throw new ArgumentException($"Enemy count cannot be negative. Got: {enemyCount}");
}
Unity Game Development Examples
Example 1: Safe File Loading
using UnityEngine;
using System.IO;
public class SaveManager : MonoBehaviour
{
public void LoadGame(string filename)
{
try
{
string filePath = Path.Combine(Application.persistentDataPath, filename);
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"Save file not found: {filename}");
}
string jsonData = File.ReadAllText(filePath);
GameData data = JsonUtility.FromJson<GameData>(jsonData);
// Apply loaded data
ApplyGameData(data);
Debug.Log("Game loaded successfully!");
}
catch (FileNotFoundException ex)
{
Debug.LogWarning($"Save file not found: {ex.Message}");
// Start new game instead
StartNewGame();
}
catch (System.Exception ex)
{
Debug.LogError($"Error loading game: {ex.Message}");
// Show error message to player
ShowErrorMessage("Failed to load game. Starting new game.");
StartNewGame();
}
}
private void StartNewGame()
{
// Initialize new game
}
private void ApplyGameData(GameData data)
{
// Apply loaded data to game
}
}
Example 2: Safe Array Access
using UnityEngine;
public class Inventory : MonoBehaviour
{
private Item[] items = new Item[10];
public Item GetItem(int index)
{
try
{
if (index < 0 || index >= items.Length)
{
throw new IndexOutOfRangeException(
$"Index {index} is out of range. Valid range: 0-{items.Length - 1}");
}
return items[index];
}
catch (IndexOutOfRangeException ex)
{
Debug.LogWarning($"Invalid inventory index: {ex.Message}");
return null;
}
}
public void SetItem(int index, Item item)
{
try
{
if (index < 0 || index >= items.Length)
{
throw new ArgumentException($"Invalid index: {index}");
}
items[index] = item;
}
catch (ArgumentException ex)
{
Debug.LogError($"Cannot set item: {ex.Message}");
}
}
}
Example 3: Safe Component Access
using UnityEngine;
public class PlayerController : MonoBehaviour
{
private Rigidbody rb;
void Start()
{
try
{
rb = GetComponent<Rigidbody>();
if (rb == null)
{
throw new MissingComponentException(
"Rigidbody component not found! Please add a Rigidbody to this GameObject.");
}
}
catch (MissingComponentException ex)
{
Debug.LogError($"Component error: {ex.Message}");
// Add component automatically
rb = gameObject.AddComponent<Rigidbody>();
Debug.Log("Rigidbody component added automatically.");
}
}
public void Move(Vector3 direction)
{
try
{
if (rb == null)
{
throw new NullReferenceException("Rigidbody is null!");
}
rb.velocity = direction * 5f;
}
catch (NullReferenceException ex)
{
Debug.LogError($"Cannot move: {ex.Message}");
}
}
}
Debugging Techniques
Debugging is the process of finding and fixing bugs in your code. Here are essential debugging techniques.
Using Debug.Log
Unity's Debug.Log prints messages to the Console window.
using UnityEngine;
public class DebugExample : MonoBehaviour
{
void Start()
{
Debug.Log("Game started!");
Debug.LogWarning("This is a warning!");
Debug.LogError("This is an error!");
}
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
Debug.Log($"Space pressed at frame {Time.frameCount}");
}
}
}
Debugging Variables
public class Player : MonoBehaviour
{
public int health = 100;
public int score = 0;
void Update()
{
// Log variable values
Debug.Log($"Health: {health}, Score: {score}");
// Conditional logging
if (health <= 0)
{
Debug.LogWarning("Player health is zero or below!");
}
}
}
Using Breakpoints
Breakpoints pause execution so you can inspect variables and step through code.
In Visual Studio:
- Click left margin to set breakpoint (red dot)
- Run in Debug mode
- Code pauses at breakpoint
- Inspect variables in Watch window
- Step through code line by line
In Visual Studio Code:
- Install C# extension
- Set breakpoint (F9)
- Start debugging (F5)
- Use Debug Console to inspect variables
Conditional Debugging
public class Enemy : MonoBehaviour
{
[SerializeField] private bool debugMode = false;
void Update()
{
if (debugMode)
{
Debug.Log($"Enemy position: {transform.position}");
Debug.Log($"Enemy health: {health}");
}
}
}
Best Practices
1. Always Handle Expected Errors
// Good: Handle expected errors
public void LoadLevel(string levelName)
{
try
{
SceneManager.LoadScene(levelName);
}
catch (System.Exception ex)
{
Debug.LogError($"Failed to load level: {ex.Message}");
// Fallback to default level
SceneManager.LoadScene("MainMenu");
}
}
// Bad: Let errors crash the game
public void LoadLevel(string levelName)
{
SceneManager.LoadScene(levelName); // Crashes if level doesn't exist!
}
2. Use Specific Exception Types
// Good: Catch specific exceptions
try
{
int value = int.Parse(input);
}
catch (FormatException ex)
{
// Handle format error specifically
}
catch (OverflowException ex)
{
// Handle overflow error specifically
}
// Bad: Catch everything generically
try
{
int value = int.Parse(input);
}
catch (Exception ex)
{
// Too generic - doesn't tell us what went wrong
}
3. Don't Swallow Exceptions
// Bad: Silently ignoring errors
try
{
SaveGame();
}
catch (Exception ex)
{
// Error is ignored - player loses progress!
}
// Good: Log and handle errors
try
{
SaveGame();
}
catch (Exception ex)
{
Debug.LogError($"Save failed: {ex.Message}");
// Show error message to player
ShowErrorMessage("Failed to save game. Please try again.");
}
4. Validate Input Before Processing
// Good: Validate first, then process
public void SetScore(int newScore)
{
if (newScore < 0)
{
throw new ArgumentException("Score cannot be negative!");
}
score = newScore;
}
// Bad: Let invalid input cause errors later
public void SetScore(int newScore)
{
score = newScore; // Might cause problems later
}
5. Use Finally for Cleanup
// Good: Always clean up resources
FileStream file = null;
try
{
file = File.Open("data.txt", FileMode.Open);
// Process file
}
finally
{
file?.Close(); // Always closes, even if error occurs
}
// Bad: Resource might not be released
FileStream file = File.Open("data.txt", FileMode.Open);
// If error occurs here, file never closes!
Common Mistakes to Avoid
Mistake 1: Catching Exception Without Handling
// Wrong: Catch but don't handle
try
{
ProcessData();
}
catch (Exception ex)
{
// Empty catch block - error is ignored!
}
// Correct: Handle the error
try
{
ProcessData();
}
catch (Exception ex)
{
Debug.LogError($"Error: {ex.Message}");
// Take appropriate action
}
Mistake 2: Catching Too Broadly
// Wrong: Catch everything
try
{
// Many different operations
}
catch (Exception ex)
{
// Can't tell what went wrong
}
// Correct: Catch specific exceptions
try
{
// Operation that might fail
}
catch (FileNotFoundException ex)
{
// Handle file not found
}
catch (UnauthorizedAccessException ex)
{
// Handle access denied
}
Mistake 3: Not Using Finally for Cleanup
// Wrong: Cleanup might not happen
try
{
FileStream file = File.Open("data.txt", FileMode.Open);
// Process file
file.Close(); // If error occurs, this never runs!
}
// Correct: Use finally
FileStream file = null;
try
{
file = File.Open("data.txt", FileMode.Open);
// Process file
}
finally
{
file?.Close(); // Always runs
}
Practical Exercise
Create a safe file reader that:
- Reads a text file safely
- Handles file not found errors
- Handles permission errors
- Always closes the file
- Returns the file contents or null if error
Solution:
using System.IO;
using UnityEngine;
public class SafeFileReader
{
public static string ReadFile(string filePath)
{
FileStream file = null;
StreamReader reader = null;
try
{
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"File not found: {filePath}");
}
file = File.Open(filePath, FileMode.Open, FileAccess.Read);
reader = new StreamReader(file);
return reader.ReadToEnd();
}
catch (FileNotFoundException ex)
{
Debug.LogWarning($"File not found: {ex.Message}");
return null;
}
catch (UnauthorizedAccessException ex)
{
Debug.LogError($"Access denied: {ex.Message}");
return null;
}
catch (IOException ex)
{
Debug.LogError($"IO error: {ex.Message}");
return null;
}
finally
{
// Always close resources
reader?.Close();
file?.Close();
}
}
}
Next Steps
Now that you understand error handling, you're ready to learn about file I/O and data persistence:
- File I/O and Data Persistence - Saving and loading game data
- Advanced C# Features - LINQ, async/await, and more
- Performance Optimization - Making your code run faster
Move on to File I/O and Data Persistence to learn how to save and load game data.
Summary
- Try-catch blocks catch and handle exceptions gracefully
- Specific exceptions allow targeted error handling
- Finally blocks ensure cleanup code always runs
- Debug.Log helps you find and fix bugs
- Always handle errors - don't let your game crash
- Validate input before processing
- Use specific exception types for better error handling
- Don't swallow exceptions - log and handle them properly
Error handling is essential for creating robust, professional games. By catching and handling errors gracefully, you prevent crashes and provide a better experience for players. Practice using try-catch blocks and debugging tools to become comfortable with error handling in your game development projects.