Skip to main content

11.2 - Debugging C# Code

Debugging is an essential skill for any programmer. No matter how experienced you are, your code will inevitably contain bugs. The ability to efficiently identify and fix these issues can dramatically improve your productivity and the quality of your games.

Understanding Debugging

Debugging is the process of finding and resolving defects or problems within a program that prevent correct operation. It's much more than just fixing errors—it's about understanding how your code actually works.

Types of Bugs

Before we dive into debugging techniques, let's understand the different types of bugs you might encounter:

  1. Syntax Errors: Mistakes in the code structure that prevent compilation
  2. Runtime Errors: Errors that occur during program execution (exceptions)
  3. Logical Errors: Code that runs without crashing but produces incorrect results
  4. Performance Issues: Code that works correctly but is inefficient

Basic Debugging Techniques

1. Console Output Debugging

The simplest form of debugging is to add output statements to your code:

public void CalculateDamage(int baseDamage, float multiplier)
{
Console.WriteLine($"Base damage: {baseDamage}, Multiplier: {multiplier}");

float calculatedDamage = baseDamage * multiplier;
Console.WriteLine($"Calculated damage: {calculatedDamage}");

int finalDamage = (int)Math.Round(calculatedDamage);
Console.WriteLine($"Final damage (rounded): {finalDamage}");

// Rest of the method...
}

In Unity, you would use Debug.Log() instead of Console.WriteLine():

public void CalculateDamage(int baseDamage, float multiplier)
{
Debug.Log($"Base damage: {baseDamage}, Multiplier: {multiplier}");

float calculatedDamage = baseDamage * multiplier;
Debug.Log($"Calculated damage: {calculatedDamage}");

int finalDamage = (int)Math.Round(calculatedDamage);
Debug.Log($"Final damage (rounded): {finalDamage}");

// Rest of the method...
}

Advantages of Console Debugging:

  • Simple to implement
  • Works in any environment
  • No special tools required

Disadvantages:

  • Can clutter your code
  • Must be manually added and removed
  • Not interactive

2. Using the Debugger

Most modern IDEs (like Visual Studio) include powerful debuggers that allow you to:

  • Pause execution at specific points
  • Examine variable values
  • Step through code line by line
  • Evaluate expressions during runtime

Setting Breakpoints

A breakpoint is a marker that tells the debugger to pause execution when it reaches a specific line of code:

public void ProcessPlayerInput()
{
// The debugger will pause here if a breakpoint is set on this line
float horizontalInput = Input.GetAxis("Horizontal");
float verticalInput = Input.GetAxis("Vertical");

Vector3 movement = new Vector3(horizontalInput, 0f, verticalInput);

// You can examine the values of horizontalInput, verticalInput, and movement
// when execution is paused

ApplyMovement(movement);
}

Stepping Through Code

Once execution is paused at a breakpoint, you can:

  • Step Into: Execute the current line and, if it's a method call, jump into that method
  • Step Over: Execute the current line and move to the next line without diving into method calls
  • Step Out: Complete the execution of the current method and return to the calling method

Watching Variables

While paused, you can:

  • Hover over variables to see their current values
  • Add variables to the Watch window to monitor them continuously
  • Use the Immediate window to evaluate expressions

3. Conditional Breakpoints

Sometimes you only want to pause execution when certain conditions are met:

// In the debugger, you can set a conditional breakpoint that only triggers when:
// playerHealth <= 0
public void TakeDamage(int amount)
{
playerHealth -= amount;

if (playerHealth <= 0)
{
// The debugger will pause here only when playerHealth is zero or negative
// This helps you debug the death sequence without having to manually
// get the player to zero health each time
Die();
}
}

4. Logging Frameworks

For more sophisticated logging, consider using a logging framework that allows you to:

  • Categorize logs by severity (Debug, Info, Warning, Error)
  • Enable/disable specific categories of logs
  • Format logs consistently
  • Direct logs to different outputs (console, file, etc.)
public class GameLogger
{
public enum LogLevel
{
Debug,
Info,
Warning,
Error
}

private static LogLevel _currentLevel = LogLevel.Info;

public static void SetLogLevel(LogLevel level)
{
_currentLevel = level;
}

public static void Debug(string message)
{
if (_currentLevel <= LogLevel.Debug)
UnityEngine.Debug.Log($"[DEBUG] {message}");
}

public static void Info(string message)
{
if (_currentLevel <= LogLevel.Info)
UnityEngine.Debug.Log($"[INFO] {message}");
}

public static void Warning(string message)
{
if (_currentLevel <= LogLevel.Warning)
UnityEngine.Debug.LogWarning($"[WARNING] {message}");
}

public static void Error(string message)
{
if (_currentLevel <= LogLevel.Error)
UnityEngine.Debug.LogError($"[ERROR] {message}");
}
}

// Usage:
GameLogger.Debug("Player position updated");
GameLogger.Warning("Health is low");
GameLogger.Error("Failed to load save file");

Advanced Debugging Techniques

1. Exception Handling for Debugging

Strategic exception handling can help you identify issues:

public void LoadGameData()
{
try
{
string json = File.ReadAllText("savedata.json");
GameData data = JsonUtility.FromJson<GameData>(json);
ApplyGameData(data);
}
catch (FileNotFoundException ex)
{
Debug.LogError($"Save file not found: {ex.Message}");
CreateNewGameData();
}
catch (JsonException ex)
{
Debug.LogError($"Error parsing save file: {ex.Message}");
// Log the content of the file to help diagnose the issue
Debug.LogError($"File content: {File.ReadAllText("savedata.json")}");
CreateNewGameData();
}
catch (Exception ex)
{
Debug.LogError($"Unexpected error: {ex.GetType().Name} - {ex.Message}");
Debug.LogError($"Stack trace: {ex.StackTrace}");
CreateNewGameData();
}
}

2. Debug vs. Release Builds

You can use conditional compilation to include debug code only in development builds:

public void ProcessComplexCalculation()
{
#if DEBUG
Stopwatch stopwatch = Stopwatch.StartNew();
#endif

// Complex calculation here
PerformCalculation();

#if DEBUG
stopwatch.Stop();
Debug.Log($"Calculation took {stopwatch.ElapsedMilliseconds}ms");
#endif
}

3. Debugging Asynchronous Code

Asynchronous code can be particularly challenging to debug. Here are some strategies:

public async Task LoadResourcesAsync()
{
Debug.Log("Starting resource load...");

try
{
Task<TextAsset> configTask = LoadConfigAsync();
Task<Texture2D> textureTask = LoadTextureAsync();

// Log progress while waiting
while (!configTask.IsCompleted || !textureTask.IsCompleted)
{
Debug.Log($"Loading... Config: {(configTask.IsCompleted ? "Done" : "In progress")}, " +
$"Texture: {(textureTask.IsCompleted ? "Done" : "In progress")}");
await Task.Delay(100);
}

// Wait for both tasks to complete
await Task.WhenAll(configTask, textureTask);

TextAsset config = configTask.Result;
Texture2D texture = textureTask.Result;

Debug.Log($"Resources loaded. Config size: {config.text.Length}, " +
$"Texture dimensions: {texture.width}x{texture.height}");
}
catch (Exception ex)
{
Debug.LogError($"Error loading resources: {ex.Message}");
throw;
}
}

Debugging Strategies for Game Development

1. Visual Debugging

In game development, visual debugging can be extremely helpful:

public class EnemyAI : MonoBehaviour
{
public float detectionRadius = 5f;
public float attackRange = 2f;

private void Update()
{
// Visual debugging in the Scene view
DebugDrawDetectionRadius();
DebugDrawPathToTarget();
}

private void DebugDrawDetectionRadius()
{
// Draw detection radius (only visible in the Scene view)
Debug.DrawLine(
transform.position,
transform.position + transform.forward * detectionRadius,
Color.yellow
);

// Draw a circle for the detection radius
DebugDrawCircle(transform.position, detectionRadius, Color.yellow);
}

private void DebugDrawPathToTarget()
{
if (currentTarget != null)
{
// Draw line to current target
Debug.DrawLine(transform.position, currentTarget.position, Color.red);
}
}

private void DebugDrawCircle(Vector3 center, float radius, Color color, int segments = 20)
{
float angle = 0f;
float angleStep = 2f * Mathf.PI / segments;
Vector3 previousPoint = center + new Vector3(Mathf.Cos(angle) * radius, 0f, Mathf.Sin(angle) * radius);

for (int i = 0; i < segments + 1; i++)
{
angle += angleStep;
Vector3 nextPoint = center + new Vector3(Mathf.Cos(angle) * radius, 0f, Mathf.Sin(angle) * radius);
Debug.DrawLine(previousPoint, nextPoint, color);
previousPoint = nextPoint;
}
}
}

2. In-Game Debug Console

For complex games, consider implementing an in-game debug console:

public class DebugConsole : MonoBehaviour
{
private bool _isVisible = false;
private string _input = "";
private List<string> _outputLines = new List<string>();

private void Update()
{
// Toggle console visibility with backtick/tilde key
if (Input.GetKeyDown(KeyCode.BackQuote))
{
_isVisible = !_isVisible;
}
}

private void OnGUI()
{
if (!_isVisible)
return;

// Draw console background
GUI.Box(new Rect(0, 0, Screen.width, Screen.height / 3), "Debug Console");

// Draw output area
string output = string.Join("\n", _outputLines);
GUI.Label(new Rect(10, 20, Screen.width - 20, Screen.height / 3 - 60), output);

// Draw input field
GUI.SetNextControlName("ConsoleInput");
_input = GUI.TextField(new Rect(10, Screen.height / 3 - 30, Screen.width - 90, 20), _input);

// Focus the input field
if (_isVisible && !GUI.GetNameOfFocusedControl().Equals("ConsoleInput"))
{
GUI.FocusControl("ConsoleInput");
}

// Execute button
if (GUI.Button(new Rect(Screen.width - 70, Screen.height / 3 - 30, 60, 20), "Execute") ||
(Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Return))
{
ExecuteCommand(_input);
_input = "";
}
}

private void ExecuteCommand(string command)
{
_outputLines.Add($"> {command}");

// Process command
string[] parts = command.Split(' ');
if (parts.Length == 0)
return;

switch (parts[0].ToLower())
{
case "help":
_outputLines.Add("Available commands: help, clear, god, spawn, teleport");
break;

case "clear":
_outputLines.Clear();
break;

case "god":
bool godMode = parts.Length > 1 && parts[1].ToLower() == "on";
PlayerManager.Instance.SetGodMode(godMode);
_outputLines.Add($"God mode {(godMode ? "enabled" : "disabled")}");
break;

case "spawn":
if (parts.Length < 2)
{
_outputLines.Add("Usage: spawn [enemyType] [count=1]");
break;
}

string enemyType = parts[1];
int count = parts.Length > 2 ? int.Parse(parts[2]) : 1;

for (int i = 0; i < count; i++)
{
EnemyManager.Instance.SpawnEnemy(enemyType);
}

_outputLines.Add($"Spawned {count} {enemyType}(s)");
break;

default:
_outputLines.Add($"Unknown command: {parts[0]}");
break;
}

// Keep only the last 20 lines
if (_outputLines.Count > 20)
{
_outputLines.RemoveRange(0, _outputLines.Count - 20);
}
}
}

3. Debugging Physics Issues

Physics-based issues can be particularly challenging to debug:

public class PhysicsDebugger : MonoBehaviour
{
public bool showColliders = true;
public bool showVelocities = true;
public bool showCollisionPoints = true;

private List<ContactPoint> _contactPoints = new List<ContactPoint>();

private void OnDrawGizmos()
{
if (showColliders)
{
// Draw collider bounds
Collider[] colliders = FindObjectsOfType<Collider>();
foreach (Collider collider in colliders)
{
if (collider.enabled)
{
Gizmos.color = new Color(0, 1, 0, 0.3f);
Bounds bounds = collider.bounds;
Gizmos.DrawCube(bounds.center, bounds.size);
}
}
}

if (showVelocities)
{
// Draw rigidbody velocities
Rigidbody[] rigidbodies = FindObjectsOfType<Rigidbody>();
foreach (Rigidbody rb in rigidbodies)
{
if (rb.velocity.magnitude > 0.1f)
{
Gizmos.color = Color.blue;
Gizmos.DrawLine(rb.position, rb.position + rb.velocity);

// Draw velocity magnitude
GUIStyle style = new GUIStyle();
style.normal.textColor = Color.blue;
Handles.Label(rb.position, $"{rb.velocity.magnitude:F2} m/s", style);
}
}
}

if (showCollisionPoints)
{
// Draw recent collision points
Gizmos.color = Color.red;
foreach (ContactPoint contact in _contactPoints)
{
Gizmos.DrawSphere(contact.point, 0.1f);
Gizmos.DrawLine(contact.point, contact.point + contact.normal);
}
}
}

private void OnCollisionEnter(Collision collision)
{
if (showCollisionPoints)
{
// Store collision contact points
_contactPoints.Clear();
foreach (ContactPoint contact in collision.contacts)
{
_contactPoints.Add(contact);
}
}
}
}

Common Debugging Scenarios and Solutions

1. NullReferenceException

This is one of the most common exceptions in C#:

// Problem:
public void Attack()
{
// This will throw NullReferenceException if weapon is null
int damage = weapon.CalculateDamage();
target.TakeDamage(damage);
}

// Solution:
public void Attack()
{
// Check for null before using the reference
if (weapon == null)
{
Debug.LogError("Weapon is null in Attack() method");
return;
}

if (target == null)
{
Debug.LogError("Target is null in Attack() method");
return;
}

int damage = weapon.CalculateDamage();
target.TakeDamage(damage);
}

2. Infinite Loops

Infinite loops can freeze your application:

// Problem:
public void ProcessEnemies()
{
while (enemies.Count > 0)
{
// If this never removes an enemy, it's an infinite loop
ProcessNextEnemy();
}
}

// Solution:
public void ProcessEnemies()
{
int safetyCounter = 0;
const int MaxIterations = 1000;

while (enemies.Count > 0 && safetyCounter < MaxIterations)
{
ProcessNextEnemy();
safetyCounter++;
}

if (safetyCounter >= MaxIterations)
{
Debug.LogError("Possible infinite loop detected in ProcessEnemies()");
}
}

3. Race Conditions

Race conditions occur when the behavior depends on the timing of uncontrollable events:

// Problem:
private int _score = 0;

// This method might be called from multiple threads
public void AddPoints(int points)
{
_score += points;
}

// Solution:
private int _score = 0;
private object _scoreLock = new object();

public void AddPoints(int points)
{
lock (_scoreLock)
{
_score += points;
}
}

4. Memory Leaks

Memory leaks can cause your game to slow down over time:

// Problem:
public class EventManager : MonoBehaviour
{
public static EventManager Instance;

private void Awake()
{
Instance = this;
}

public event Action<int> OnScoreChanged;

public void UpdateScore(int newScore)
{
OnScoreChanged?.Invoke(newScore);
}
}

public class ScoreDisplay : MonoBehaviour
{
private void OnEnable()
{
// Subscribe to event
EventManager.Instance.OnScoreChanged += UpdateUI;
}

// Missing OnDisable method to unsubscribe!

private void UpdateUI(int score)
{
// Update UI
}
}

// Solution:
public class ScoreDisplay : MonoBehaviour
{
private void OnEnable()
{
// Subscribe to event
EventManager.Instance.OnScoreChanged += UpdateUI;
}

private void OnDisable()
{
// Unsubscribe from event to prevent memory leaks
EventManager.Instance.OnScoreChanged -= UpdateUI;
}

private void UpdateUI(int score)
{
// Update UI
}
}

Debugging Tools and Extensions

1. Visual Studio Debugging Features

Visual Studio offers several advanced debugging features:

  • Immediate Window: Evaluate expressions during debugging
  • Watch Window: Monitor variable values
  • Call Stack: View the sequence of method calls
  • Locals Window: See all local variables
  • Exception Settings: Configure which exceptions should break execution

2. Unity-Specific Debugging Tools

Unity provides several built-in tools for debugging:

  • Console Window: View logs, warnings, and errors
  • Profiler: Analyze performance
  • Frame Debugger: Examine rendering steps
  • Physics Debugger: Visualize colliders and physics interactions

3. Third-Party Debugging Tools

Consider these third-party tools for more advanced debugging:

  • Unity Debug Draw Extension: Enhanced visual debugging
  • Console Pro: Improved Unity console with filtering and search
  • Debugging Essentials: Collection of debugging utilities

Practical Example: Debugging a Game Mechanic

Let's walk through debugging a common game mechanic: a character controller with jumping.

The Problem

Players report that sometimes the character can't jump, even when it appears to be on the ground.

The Code with Issues

public class PlayerController : MonoBehaviour
{
public float moveSpeed = 5f;
public float jumpForce = 7f;

private Rigidbody _rigidbody;
private bool _isGrounded;

private void Start()
{
_rigidbody = GetComponent<Rigidbody>();
}

private void Update()
{
// Movement
float horizontal = Input.GetAxis("Horizontal");
float vertical = Input.GetAxis("Vertical");

Vector3 movement = new Vector3(horizontal, 0f, vertical) * moveSpeed * Time.deltaTime;
transform.Translate(movement);

// Jumping
if (Input.GetButtonDown("Jump") && _isGrounded)
{
_rigidbody.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
}
}

private void OnCollisionEnter(Collision collision)
{
_isGrounded = true;
}

private void OnCollisionExit(Collision collision)
{
_isGrounded = false;
}
}

Debugging Process

  1. Identify the issue: The player can't jump sometimes, even when appearing to be on the ground.

  2. Add debug visualization:

private void OnDrawGizmos()
{
// Visualize grounded state
Gizmos.color = _isGrounded ? Color.green : Color.red;
Gizmos.DrawSphere(transform.position - new Vector3(0, 0.5f, 0), 0.2f);
}
  1. Add logging:
private void Update()
{
// Existing movement code...

// Jumping with debug logs
if (Input.GetButtonDown("Jump"))
{
Debug.Log($"Jump button pressed. IsGrounded: {_isGrounded}");

if (_isGrounded)
{
_rigidbody.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
Debug.Log("Jump force applied");
}
else
{
Debug.Log("Jump failed - not grounded");
}
}
}

private void OnCollisionEnter(Collision collision)
{
_isGrounded = true;
Debug.Log($"Collision enter with {collision.gameObject.name}. IsGrounded set to true.");
}

private void OnCollisionExit(Collision collision)
{
_isGrounded = false;
Debug.Log($"Collision exit with {collision.gameObject.name}. IsGrounded set to false.");
}
  1. Test and observe: After testing, we notice that when the player walks off one platform onto another, OnCollisionExit is called when leaving the first platform, but OnCollisionEnter isn't always called when landing on the second platform if the collision is continuous.

  2. Fix the issue:

public class PlayerController : MonoBehaviour
{
public float moveSpeed = 5f;
public float jumpForce = 7f;
public LayerMask groundLayer;
public float groundCheckDistance = 0.2f;

private Rigidbody _rigidbody;

private void Start()
{
_rigidbody = GetComponent<Rigidbody>();
}

private void Update()
{
// Movement
float horizontal = Input.GetAxis("Horizontal");
float vertical = Input.GetAxis("Vertical");

Vector3 movement = new Vector3(horizontal, 0f, vertical) * moveSpeed * Time.deltaTime;
transform.Translate(movement);

// Jumping
if (Input.GetButtonDown("Jump") && IsGrounded())
{
_rigidbody.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
Debug.Log("Jump force applied");
}
}

private bool IsGrounded()
{
// Cast a ray downward to check if the player is grounded
bool grounded = Physics.Raycast(
transform.position,
Vector3.down,
groundCheckDistance,
groundLayer
);

// Visualize the ground check
Debug.DrawRay(
transform.position,
Vector3.down * groundCheckDistance,
grounded ? Color.green : Color.red
);

return grounded;
}

private void OnDrawGizmos()
{
// Visualize ground check distance
Gizmos.color = IsGrounded() ? Color.green : Color.red;
Gizmos.DrawSphere(transform.position + Vector3.down * groundCheckDistance, 0.1f);
}
}
  1. Verify the fix: Test the solution to ensure the player can now jump consistently when on the ground.

Conclusion

Debugging is both an art and a science. It requires analytical thinking, patience, and a methodical approach. By mastering the debugging techniques covered in this section, you'll be able to identify and fix issues in your code more efficiently, leading to more stable and polished games.

Remember that debugging is not just about fixing bugs—it's also about understanding your code better. Each debugging session is an opportunity to learn more about how your code works and how to improve it.

Unity Relevance

Unity provides several built-in debugging tools that complement the C# debugging techniques we've covered:

  1. Debug.Log Variants:

    • Debug.Log(): Standard log message
    • Debug.LogWarning(): Warning message (yellow)
    • Debug.LogError(): Error message (red)
    • These messages appear in the Unity Console window and can include a reference to a GameObject
  2. Visual Debugging:

    • Debug.DrawLine(), Debug.DrawRay(): Draw lines in the Scene view
    • Gizmos.DrawSphere(), Gizmos.DrawCube(): Draw shapes in the Scene view
    • These are invaluable for visualizing physics, AI, and other game systems
  3. Unity Profiler:

    • Helps identify performance bottlenecks
    • Can profile CPU, GPU, memory, audio, and more
    • Essential for optimizing your game
  4. Unity Frame Debugger:

    • Breaks down the rendering process step by step
    • Helps identify rendering issues and optimize graphics

These tools, combined with the C# debugging techniques we've covered, give you a powerful toolkit for solving problems in your Unity games.

In the next section, we'll explore C# 9 features that are particularly relevant for Unity development.