05.2 - Exceptions, Debugging, Code Quality
Recap
05.1 - Unit Testing gave you a Tests project that tells you that something is wrong; this lecture is about what happens next — how the engine and repositories from 04.2 - Files & Persistence should fail, how to find the line that is wrong, and how to make the whole solution presentable for D1.
By the end of this lecture you should be able to:
- Use
try/catch/finallyand exception filters correctly, and write a custom exception for your engine. - Decide between throwing and returning a result object, and validate input with guard clauses.
- Debug a rule bug in Rider with conditional breakpoints, watches and Evaluate Expression.
- Recognise the three classic board bugs (row/column swap, off-by-one, wrap-around) on sight.
- Prepare the repository for D1: naming, layout, analyzers, README and git hygiene.
Lecture demos: csharp-2026-fall
Exceptions
An exception is how .NET says "this method cannot do what its name promises". Thrown at one place, caught at another — every frame in between is unwound. If nobody catches it, the process ends with a stack trace.
try
{
var state = gameRepository.Get(id);
RunGameLoop(state);
}
catch (KeyNotFoundException)
{
Console.WriteLine("That saved game no longer exists.");
}
catch (IOException e)
{
Console.WriteLine($"Could not read the save file: {e.Message}");
}
finally
{
Console.CursorVisible = true; // runs whether we return, throw or catch
}
Rules that matter:
- Catch blocks are tried top to bottom — put the most specific type first.
catch (Exception)at the top makes every other catch unreachable (the compiler tells you). finallyalways runs. Use it for cleanup that must happen; forIDisposableobjects preferusing, which isfinallyin disguise.- Re-throw with a bare
throw;to keep the original stack trace.throw e;resets it and destroys the one piece of information you need.
Exception filters
when decides whether a catch block applies without unwinding the stack. It reads better than a nested if and keeps the original exception alive when the filter says no:
try
{
gameRepository.Save(state);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
Console.WriteLine($"Save failed: {e.Message}. Check the folder permissions.");
}
Custom exceptions
When the engine refuses a move, ArgumentException is not wrong, but it does not tell the UI which cell and why. A domain exception does:
namespace GameEngine;
public class InvalidMoveException : Exception
{
public int Row { get; }
public int Col { get; }
public InvalidMoveException(int row, int col, string message)
: base($"Move ({row}, {col}) rejected: {message}")
{
Row = row;
Col = col;
}
}
Derive from Exception, name it ...Exception, keep it in the project that throws it (GameEngine), carry the data the catcher needs. One or two per library is plenty; you do not need an exception per rule.
try { repo.Save(state); } catch { } // the save silently failed and the user walks away happy
An empty catch turns a loud, fixable bug into data loss that is discovered at the defense. If you cannot handle it, do not catch it. If you catch it, at least tell the user.
Throwing is roughly a thousand times slower than returning, and catch as an if makes the normal path unreadable. A rejected user move on a hot-seat board happens all the time — that is a normal outcome, not an exceptional one. Which brings us to the next section.
Throw or return?
Two legitimate designs for MakeMove:
Throw. The engine assumes callers only ever ask for legal moves (they got them from GetLegalMoves()), so an illegal one is a programming error — throw InvalidMoveException and let the test catch it.
Return a result. The engine treats a bad move as a normal answer and returns an object the UI can display:
public record MoveResult(bool IsValid, string? Error = null)
{
public static MoveResult Ok() => new(true);
public static MoveResult Fail(string error) => new(false, error);
}
public MoveResult TryMakeMove(int row, int col)
{
if (!IsInsideBoard(row, col)) return MoveResult.Fail("Outside the board.");
if (State.Board[row][col] != EGamePiece.Empty) return MoveResult.Fail("Cell is taken.");
State.Board[row][col] = State.NextMoveBy;
State.Moves.Add((row, col));
State.NextMoveBy = State.NextMoveBy == EGamePiece.X ? EGamePiece.O : EGamePiece.X;
return MoveResult.Ok();
}
Guidance:
- User mistakes that you expect (taken cell, out of range, grid move before unlock) → result object, or validate in the UI before calling the engine.
- Impossible states (a piece of
Emptyin the move list, a board that does not match the configuration) → throw. - Both at once is fine —
TryMakeMovereturns,MakeMovethrows by callingTryMakeMoveand checking. Pick one for the UI and be consistent across the solution. - The AI in A5 will call move generation thousands of times per second; it must never go through an exception path.
Guard clauses and validation
A guard clause is the if (...) throw at the top of a method that turns a confusing failure deep inside into a clear one at the door. .NET has static helpers so the guards stay one line:
public void Save(GameConfiguration config)
{
ArgumentNullException.ThrowIfNull(config);
ArgumentException.ThrowIfNullOrWhiteSpace(config.Name);
ArgumentOutOfRangeException.ThrowIfLessThan(config.WinLength, 3);
File.WriteAllText(PathFor(config.Name), JsonSerializer.Serialize(config, JsonOptions));
}
public GameState Get(Guid id)
{
var path = PathFor(id);
if (!File.Exists(path))
{
throw new KeyNotFoundException($"Saved game {id} does not exist.");
}
return JsonSerializer.Deserialize<GameState>(File.ReadAllText(path), JsonOptions)
?? throw new InvalidDataException($"Save file {path} is empty or corrupt.");
}
The ?? throw pattern converts the nullable Deserialize<T> result into a non-nullable one — with nullable warnings as errors, you need this anyway.
Validating GameConfiguration
Configuration comes from the user and from files, so validation is a normal outcome: collect every problem, do not stop at the first one, and let the UI show the list. Throw only where an invalid configuration must never arrive — the engine constructor.
public static class GameConfigurationValidator
{
public static List<string> Validate(GameConfiguration c)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(c.Name)) errors.Add("Name is required.");
if (c.BoardWidth is < 3 or > 20) errors.Add("Board width must be between 3 and 20.");
if (c.BoardHeight is < 3 or > 20) errors.Add("Board height must be between 3 and 20.");
if (c.WinLength < 3) errors.Add("Winning length must be at least 3.");
if (c.WinLength > Math.Max(c.BoardWidth, c.BoardHeight))
{
errors.Add("Winning length cannot be longer than the board.");
}
return errors;
}
}
// in GameBrain
public GameBrain(GameState state)
{
ArgumentNullException.ThrowIfNull(state);
var errors = GameConfigurationValidator.Validate(state.Config);
if (errors.Count > 0)
{
throw new ArgumentException(string.Join(" ", errors), nameof(state));
}
State = state;
}
Add the rules of your game: grid size ≤ board size, even board sizes for Reversi, pieces per player, unlock threshold ≤ pieces. Each rule is a [Theory] row in Tests — see the [MemberData] example in lecture 25.
Debugging in Rider
Console.WriteLine debugging works until the bug is in a loop that runs 42 times. Learn the debugger once; it pays for itself in the first week.
Breakpoints
- Click the gutter (or use the Toggle Line Breakpoint action) on a line; start with Debug instead of Run. The program stops before executing that line.
- Conditional breakpoint — right-click the red dot and add a condition, e.g.
row == 5 && col == 0. The debugger stops only for the interesting iteration instead of you pressing Resume forty times. - Hit count / log message — a breakpoint that does not suspend but prints an expression to the console is a
Console.WriteLineyou never have to remove. - Exception breakpoints — Run → View Breakpoints → add a .NET Exception Breakpoint. Stop where an
InvalidMoveException(or any exception) is thrown, not where it is caught three layers up. Tick "only unhandled" if your code throws on purpose.
While stopped
| Action | What it does |
|---|---|
| Step Over | run the current line, stop at the next one in this method |
| Step Into | enter the method being called on this line |
| Step Out | run until the current method returns |
| Run to Cursor | continue until the line under the caret |
| Resume | continue until the next breakpoint |
| Evaluate Expression | type any expression — State.Board[row].Count(p => p == EGamePiece.X) — and see the value |
| Watches | pin an expression; re-evaluated after every step |
| Immediate window | evaluate and run code in the current frame, e.g. call brain.GetLegalMoves() |
Shortcuts depend on the keymap you chose when installing Rider — the Run menu shows them next to each action.
The Variables pane shows every local and field in the current frame; expand a jagged array to see the whole board. The Frames pane is the call stack — click a frame to see that method's variables, including who called your engine with a wrong argument.
Debugging a test
The fastest debugging loop for rule bugs: write the failing [Fact] with the board picture, click the gutter icon → Debug, put a breakpoint in CheckWin. You get a deterministic, tiny scenario, no menus to click through, and the test stays as a regression guard afterwards.
Common board bugs
You will write all three of these at least once. Knowing their symptoms saves hours.
Row / column swap
// Board is EGamePiece[rows][cols]
board[col][row] = piece; // works on a square board, crashes or misplaces on 7x6
Symptom: everything passes on 3×3, breaks on a rectangular board. Cure: name things row/col everywhere (never x/y in one file and i/j in the next), and add a rectangular test board to every theory.
Off-by-one
for (var c = 0; c <= config.BoardWidth; c++) // one too many
for (var c = 0; c < config.BoardWidth - winLength; c++) // one too few — a line ending on the edge is never checked
Symptom: IndexOutOfRangeException, or a win on the last column that is not detected. Cure: the edge tests from lecture 05.1, and helper methods like IsInsideBoard(row, col) used everywhere instead of ad-hoc comparisons.
Cylinder wrap and negative modulo
var wrapped = (col + step) % width; // -1 % 7 == -1 in C# → index -1
var wrapped = ((col + step) % width + width) % width; // always 0..width-1
C#'s % keeps the sign of the left operand. Anything that walks left or up along a wrapping edge hits this. Same family: forgetting that a wrapped line must not count the same cell twice on a very narrow board.
And the rest
- Checking for a win before placing the piece — the winning move is never detected.
- Forgetting to switch
NextMoveByafter a move, or switching it also after a rejected one. - Copying a jagged array with
board.ToArray()— that copies the row references; the AI's "what-if" board mutates the real one. Copy each row. DateTime.Nowin one place andDateTime.UtcNowin another — saves sort wrongly after the clocks change.
Code quality
Naming and size
- Types and members
PascalCase, locals and parameterscamelCase, private fields_camelCase. Interfaces start withI, enums withEin this course (EGamePiece), async methods end withAsync. - Names say what, not how:
IsCellFree(row, col)notchk(r, c). Booleans read as questions:IsDraw,HasWinner,CanFly. - A method does one thing and fits on a screen.
CheckWinthat also prints the board and saves the game is three methods. - No magic numbers:
if (moves.Count >= config.UnlockThreshold)instead of>= 2. - Comments say why. If a comment says what the next line does, rename the line instead.
SOLID-lite
Two of the five letters are enough for D1; the repository pattern and dependency injection follow in lecture 08.1, and the rest of SOLID with the classical patterns in lecture 41.
- Single Responsibility —
GameBrainknows the rules and nothing aboutConsole;ConsoleUIdraws and asks, and never decides whether a move is legal;DAL.Jsonreads and writes files and never validates rules. If a class hasusing System.Text.JsonandConsole.WriteLine, it has two jobs. - Dependency Inversion —
ConsoleUIdepends onIGameRepository, not onJsonGameRepository. Concrete classes are created in exactly one place (Program.csfor now, a DI container in Week 8). This is what makes A4's "switch to the database with a couple of lines" possible.
The dependency direction of the solution, which must have no cycles:
MenuSystem references nothing; GameEngine references nothing. That is not an accident — it is what lets the same libraries serve the web app in A6.
Tooling that enforces it
Directory.Build.props at the solution root applies to every project — this is where the mandatory nullable-as-errors setting lives:
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<WarningsAsErrors>Nullable</WarningsAsErrors>
<!-- stricter, recommended once the build is clean: -->
<!-- <TreatWarningsAsErrors>true</TreatWarningsAsErrors> -->
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
</Project>
- Roslyn analyzers ship with the SDK;
AnalysisLevelturns on the recommended set (unused parameters, disposables not disposed,string.Comparemisuse, ...). Warnings appear in the build output and in Rider's editor. .editorconfigat the solution root fixes formatting and naming so that Rider,dotnet formatand every teammate agree:
root = true
[*.cs]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
insert_final_newline = true
csharp_style_var_for_built_in_types = true:suggestion
csharp_prefer_braces = true:warning
dotnet_style_qualification_for_field = false:suggestion
dotnet_naming_rule.private_fields_underscore.symbols = private_fields
dotnet_naming_rule.private_fields_underscore.style = underscore_camel
dotnet_naming_rule.private_fields_underscore.severity = warning
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_style.underscore_camel.required_prefix = _
dotnet_naming_style.underscore_camel.capitalization = camel_case
- Rider: Code → Inspect Code on the solution before a submission; Code → Reformat and Cleanup on a file.
dotnet formatdoes the same from the CLI.
README and git hygiene
README.md
Mandatory contents (the TA reads it first):
- Full name, student code, school e-mail, uni-id, assigned game.
- How to build and run:
dotnet build,dotnet run --project ConsoleUI,dotnet test. - Where saves live (the user-home folder you chose) — the TA needs to find and delete them.
- Which custom-rule extensions are implemented and where (class names).
- Later: AI timing notes (A5), web run instructions and the AI usage log (A6).
Git
- Small commits, often. One commit per feature or fix, with an imperative message:
Add cylinder wrap to CheckWin,Fix off-by-one on last column. Notstuff, notfinal final v2. .gitignorefromdotnet new gitignoreplus Rider's.idea/folder. Never commitbin/,obj/,*.db, save files orTestResults/.- No absolute local paths in code; the repository must build on the TA's machine.
- Tag your submissions. Code freeze is enforced; a tag is your proof and your fallback if the branch moves on:
git tag -a d1 -m "D1 submission: A1 + A2 + A3"
git push origin d1
If you keep working after the deadline (you should — A4 starts), the TA checks out d1. If something breaks on main during the defense, you check out d1.
- Commit before every risky refactoring.
git stashandgit checkout -- fileare your undo buttons.
D1 checklist
D1 = A1 + A2 + A3, defended in Weeks 6–7. Walk through this the evening before.
| Area | Check | Assignment |
|---|---|---|
| Repo | created as defined in Git usage, README complete, .gitignore, tag pushed | all |
| Build | dotnet build and dotnet test succeed from a fresh clone, nullable-as-errors in Directory.Build.props | all |
| Menu | separate library, no reference to the game, unlimited depth, mandatory items per level, unique hot keys, delegates, updatable labels | A1 |
| Engine | rules + both mandatory extensions, GetLegalMoves, CheckWin all directions, draw, validation of configuration | A2 |
| UI | configuration CRUD with validation and presets, board drawing, hot-seat flow, no rule logic in the UI | A2 |
| Tests | win in every direction and at edges, each extension on/off, invalid moves, draw, JSON round-trips | A2 + A3 |
| Persistence | IConfigRepository / IGameRepository in the shared library, DAL.Json implementation, saves under user home, list/load/continue/delete | A3 |
| Quality | naming, small methods, no Console in the engine, no absolute paths, small commits | all |
Typical TA questions: "Show me where the win is detected for the diagonal." "What happens if I enter a board width of 1?" "Delete this saved game — where did the file go?" "Why is the menu library not allowed to reference the game?" "Which test would fail if I broke the cylinder rule?" — you must be able to answer each one by navigating to the code, not by guessing.
Self preparation QA
Be prepared to explain topics like these:
- What is the difference between
throw;andthrow e;inside a catch block? —throw;re-throws and keeps the original stack trace;throw e;resets the trace to the current line and hides where the problem actually started. - When do you throw and when do you return a result object? — Throw for programming errors and impossible states; return a result (or validate beforehand) for expected user outcomes such as a rejected move, because exceptions are slow and make the normal path unreadable.
- What is a guard clause and which .NET helpers make them one-liners? — An early
if (...) throwat the top of a method;ArgumentNullException.ThrowIfNull,ArgumentException.ThrowIfNullOrWhiteSpace,ArgumentOutOfRangeException.ThrowIfLessThanand friends. - Why is an empty catch block dangerous? — It hides the failure, so data is lost or state is corrupt while the program pretends everything worked; if you cannot handle an exception, let it propagate.
- What is a conditional breakpoint and when do you use it? — A breakpoint with an expression such as
row == 5 && col == 0that only suspends when the expression is true; use it inside loops to stop at the interesting iteration. - Why does
(col - 1) % widthbreak a cylinder board? — C#'s%keeps the sign of the dividend, so-1 % 7is-1; use((n % width) + width) % widthto wrap correctly. - What does Single Responsibility mean for
GameBrain,ConsoleUIandDAL.Json? — Rules only, presentation only, storage only; a class that both serializes JSON and writes to the console has two reasons to change. - Why tag the D1 commit? — It records exactly what was submitted before the code freeze, lets the TA check out the graded version, and gives you a working fallback if
mainbreaks during the defense.