Exceptions
Summary
An exception is a signal that something went wrong during execution — a missing file, an invalid argument, dividing by zero — that interrupts normal control flow and propagates upward until something explicitly catches and handles it, or the program terminates. Exceptions exist so error handling doesn't have to be manually threaded through every return value in a call chain.
Mental Model
Think of a normal function call chain as a relay race: each function does
its work and hands the baton to the next. An exception is like a runner
suddenly shouting "stop the race!" and refusing to hand off the baton
normally — instead, they hand up a note explaining what went wrong, and
that note skips every runner ahead of it in the chain until someone
specifically says "I'll read that note" (a catch block). If nobody ever
says that, the note reaches the race organizer (the runtime), who stops the
whole race (crashes the program) and prints the note for everyone to see.
This is fundamentally different from a function returning false or -1
to signal failure: a return value only reaches its immediate caller and
is trivially ignorable, while an unhandled exception forcibly interrupts
every level of the call stack until it's dealt with.
Core Explanation
- Throwing.
throw new ArgumentException("amount must be positive");creates an exception object and immediately halts normal execution at that point, unwinding the call stack. - Propagation (unwinding). If the function that threw doesn't handle
it, the exception moves up to its caller, then that caller's caller, and
so on — each frame's remaining code in that call is skipped — until a
matching
catchis found or the stack runs out. - Catching. A
try { ... } catch (ArgumentException ex) { ... }block intercepts a matching exception type, letting the program recover (retry, log, show an error, substitute a default) instead of crashing. Multiplecatchclauses can handle different exception types differently; a broadercatch (Exception ex)catches anything not already matched. finally. Code in afinallyblock runs whether or not an exception was thrown — the standard place to release a resource (close a file, unsubscribe an event) that must clean up either way.- Cost. Throwing and catching an exception is meaningfully slower than a normal return in most languages, because unwinding the stack and capturing debug information (a stack trace) is real work — this is why exceptions are for exceptional situations, not routine control flow (see Common Mistakes).
Syntax / Examples
C# (Unity)
public class SaveLoader : MonoBehaviour
{
public PlayerData Load(string path)
{
try
{
string json = System.IO.File.ReadAllText(path);
return JsonUtility.FromJson<PlayerData>(json);
}
catch (System.IO.FileNotFoundException)
{
Debug.LogWarning($"No save found at {path}, using defaults.");
return new PlayerData();
}
catch (System.Exception ex)
{
Debug.LogError($"Save file at {path} is corrupted: {ex.Message}");
return new PlayerData();
}
finally
{
Debug.Log("Load attempt finished.");
}
}
}
C++ (Unreal)
Unreal Engine builds with C++ exceptions disabled by default for
performance and platform-compatibility reasons; error handling in Unreal
C++ conventionally uses return codes, bool out-parameters, or TOptional
instead:
bool USaveLoader::TryLoad(const FString& Path, FPlayerData& OutData)
{
FString Json;
if (!FFileHelper::LoadFileToString(Json, *Path))
{
UE_LOG(LogTemp, Warning, TEXT("No save found at %s, using defaults."), *Path);
OutData = FPlayerData();
return false;
}
return FJsonObjectConverter::JsonObjectStringToUStruct(Json, &OutData);
}
Unreal Blueprint
Blueprint has no try/catch construct; the equivalent pattern is a
function with a Success boolean output pin (or a Branch node
checking a validity/error output), matching the C++ return-code convention
above rather than the C# try/catch convention.
Practical Example
Parsing a player-entered number (a chat command argument, a settings menu text field) is a common source of exceptions that must be handled instead of allowed to crash the game.
public bool TryParsePlayerCount(string input, out int count)
{
try
{
count = int.Parse(input);
if (count < 1 || count > 8)
{
Debug.LogWarning($"Player count {count} out of range.");
count = 0;
return false;
}
return true;
}
catch (System.FormatException)
{
Debug.LogWarning($"'{input}' is not a valid number.");
count = 0;
return false;
}
}
Note that the out-of-range check does not throw — it's an expected,
routine outcome of user input, handled with a normal if/return, exactly
as Common Mistakes recommends below. Only the genuinely unexpected case —
text that isn't a number at all — goes through the catch.
Common Mistakes
- Using exceptions for expected, routine control flow. Throwing to
signal "inventory is full" or "player pressed Cancel" — outcomes that
happen constantly and are entirely expected — is slow and makes normal
logic paths hard to follow. Return a
bool/enum/Result-style value for anything that isn't genuinely exceptional. - Catching
Exceptionbroadly and silently swallowing it.catch (Exception) { }with an empty body hides real bugs — the failure disappears with no log, no stack trace, nothing to debug later. At minimum, log what was caught. - Catching an exception far too broadly around a large block of code,
making it unclear which specific line actually failed. Keep
tryblocks as small as practical around the operation that can actually throw. - Not cleaning up resources when an exception occurs mid-operation —
a file left open, an event handler left subscribed — because the code
after the throwing line was skipped. Use
finally(or ausingblock, which isfinallyunder the hood) for anything that must be released regardless of success or failure.
Best Practices
- Reserve exceptions for genuinely unexpected failures (corrupted data,
missing files, programmer errors like a null argument) — use return
values or
TryX(out result)patterns for expected, routine outcomes. - Catch the most specific exception type that's meaningful to handle differently, and let anything else propagate rather than guessing at a generic recovery.
- Always log (at minimum) any exception that's caught and not rethrown, so a swallowed failure doesn't disappear without a trace.
- Use
finally(orusing) for cleanup that must happen whether or not an exception occurred, rather than duplicating cleanup code in both the success and catch paths.
Exercises
- Write
TryParsePlayerCount(as above) and then a second version that throws a custom exception instead of returningbool. Argue which version is the better fit for validating a settings-menu text field, using the "expected vs. exceptional" distinction from this document. - Reproduce an unhandled
NullReferenceExceptionon purpose in a small script, observe what the console reports, then wrap the call in atry/catchand compare the program's behavior with and without it. - Take the
SaveLoader.Loadexample and add afinallyblock that logs how long the load attempt took, regardless of success or failure. Explain whyfinallyis the right place for this rather than the end of thetryblock. - Explain, for an Unreal C++ project with exceptions disabled, how the
TryLoad-with-bool-return pattern achieves the same practical outcome as a C#try/catch, and what it cannot express that exceptions can.
Related Concepts
- Debugging — reading a stack trace produced by an unhandled or logged exception is one of the most common starting points for a debugging session.
- Classes — custom exception types are ordinary classes that inherit from a base exception class, carrying whatever extra data the failure needs to describe.
- Strings — parsing and validating string input from players or files is one of the most common sources of exceptions in a game codebase.
References
KNOWLEDGE_SCHEMA.md— GDKB's concept maturity ladder and cross-linking rules, which this document follows.- Unity/.NET — Exception Handling: https://learn.microsoft.com/dotnet/standard/exceptions/
- Unreal Engine — C++ Coding Standard (exceptions policy): https://dev.epicgames.com/documentation/en-us/unreal-engine/epic-cplusplus-coding-standard-for-unreal-engine