Chapter 4

C# Crash Course for Unity

Everything you need to know about C# to start building game systems in Unity. Variables, methods, classes, the MonoBehaviour lifecycle, and more — explained with practical, game-focused examples.

C# (pronounced "C sharp") is the programming language Unity uses. If you've never programmed before, this chapter will teach you everything you need to write the scripts in this course. If you've programmed in another language, this will get you up to speed on C#'s Unity-specific patterns quickly.

We're not trying to teach you all of C# — that would take an entire book. Instead, we're covering the subset you'll actually use when building our infinite runner. Every concept is demonstrated with code you could paste into a Unity script and run.

Creating Your First C# Script

Let's create a script and break down every line Unity generates for us.

  1. In Unity, right-click in the Project window on the Assets folder.
  2. Choose Create → C# Script.
  3. Name it MyFirstScript. The name is important — it must match the class name inside the file exactly (including capitalization).
  4. Double-click the script to open it in your code editor.

You'll see this code:

MyFirstScript.csC#
using UnityEngine;

public class MyFirstScript : MonoBehaviour
{
    void Start()
    {

    }

    void Update()
    {

    }
}

Let's break down every single piece:

⛔ Script Name Must Match Class Name

If you name your file PlayerController.cs, the class inside must be called PlayerController. If they don't match, Unity will show an error and refuse to attach the script to any GameObject. If you rename a script file in the Project window, you must also rename the class inside the file manually.

The MonoBehaviour Lifecycle

Unity calls certain methods on your scripts automatically at specific times. You don't call these methods yourself — Unity calls them. You just define them and write code inside. Here are the ones you'll use most, in the order Unity calls them:

LifecycleDemo.csC#
using UnityEngine;

public class LifecycleDemo : MonoBehaviour
{
    // Called ONCE when the script instance is loaded.
    // Runs before Start(). Use for setting up references
    // that other scripts might need in their Start().
    void Awake()
    {
        Debug.Log("Awake - I exist!");
    }

    // Called when the script/GameObject is enabled.
    // Can be called multiple times if you disable and re-enable.
    void OnEnable()
    {
        Debug.Log("OnEnable - I'm active!");
    }

    // Called ONCE, on the first frame the script is active.
    // Runs after Awake(). Use for initialization that depends
    // on other objects already being set up.
    void Start()
    {
        Debug.Log("Start - Let's go!");
    }

    // Called every frame. The time between calls varies
    // depending on frame rate. Use for input, visuals,
    // and non-physics game logic.
    void Update()
    {
        // This runs 60+ times per second!
    }

    // Called at a FIXED time interval (default: 50 times/sec).
    // Use for physics calculations. The interval is consistent
    // regardless of frame rate.
    void FixedUpdate()
    {
        // Physics code goes here
    }

    // Called every frame AFTER all Update() methods have run.
    // Use for camera movement that needs to follow objects
    // that moved during Update().
    void LateUpdate()
    {
        // Camera follow code goes here
    }

    // Called when the script/GameObject is disabled.
    void OnDisable()
    {
        Debug.Log("OnDisable - I'm off!");
    }

    // Called when the GameObject is destroyed.
    // Use for cleanup: unsubscribing from events, etc.
    void OnDestroy()
    {
        Debug.Log("OnDestroy - Goodbye!");
    }
}

The order is: Awake → OnEnable → Start → (Update / FixedUpdate / LateUpdate loop) → OnDisable → OnDestroy.

💡 When to Use Update vs. FixedUpdate

Update() runs once per frame. Frame rate varies — it might run 30 times/sec on a slow device and 144 times/sec on a fast one. Use Update for input handling, UI updates, and visual logic. FixedUpdate() runs at a fixed interval (every 0.02 seconds by default, or 50 times/sec). Use FixedUpdate for physics: applying forces, moving Rigidbody objects, and anything that needs consistent timing regardless of frame rate. In our runner, player input will be in Update, but physics-based movement will be in FixedUpdate.

Variables and Data Types

A variable is a named container that holds a value. Think of it as a labeled box. The label is the variable name, and the contents are the value. In C#, every variable has a type that defines what kind of value it can hold.

VariablesDemo.csC#
using UnityEngine;

public class VariablesDemo : MonoBehaviour
{
    // Integer: whole numbers (no decimal point)
    // Use for: scores, lives, lane index, counts
    int score = 0;
    int lives = 3;
    int currentLane = 1; // 0 = left, 1 = center, 2 = right

    // Float: decimal numbers (must end with 'f')
    // Use for: speed, positions, timers, percentages
    float moveSpeed = 10.5f;
    float jumpHeight = 2.0f;
    float timer = 0f;

    // Bool: true or false (only two possible values)
    // Use for: is the player alive? is the game paused?
    bool isAlive = true;
    bool isJumping = false;
    bool isGameOver = false;

    // String: text (wrapped in double quotes)
    // Use for: player name, UI text, messages
    string playerName = "Runner";
    string gameOverMessage = "Game Over!";

    // Vector3: three floats grouped together (x, y, z)
    // Use for: positions, directions, movement
    Vector3 startPosition = new Vector3(0f, 1f, 0f);
    Vector3 moveDirection = Vector3.forward;

    // Vector2: two floats grouped together (x, y)
    // Use for: 2D positions, screen coordinates, touch input
    Vector2 screenCenter = new Vector2(960f, 540f);

    void Start()
    {
        Debug.Log("Player: " + playerName);
        Debug.Log("Speed: " + moveSpeed);
        Debug.Log("Alive: " + isAlive);
        Debug.Log("Start pos: " + startPosition);
    }
}

The Most Common Types You'll Use

Type What It Holds Example
int Whole numbers 42, -7, 0, 1000
float Decimal numbers 3.14f, -0.5f, 100.0f
bool True or false true, false
string Text "hello", "Game Over"
Vector3 3D coordinates (x, y, z) new Vector3(1f, 2f, 3f)
Vector2 2D coordinates (x, y) new Vector2(5f, 10f)
⚠️ Don't Forget the 'f' on Floats

In C#, decimal numbers are double by default (double-precision). Unity uses float (single-precision) for almost everything. You must add an f suffix to tell the compiler "this is a float": 10.5f, not 10.5. Forgetting the f will cause a compile error. This trips up every beginner at least once.

Access Modifiers: public, private, and [SerializeField]

Access modifiers control who can see and change a variable. This is crucial in Unity because they also determine what appears in the Inspector.

AccessModifiers.csC#
using UnityEngine;

public class AccessModifiers : MonoBehaviour
{
    // PUBLIC: visible in Inspector, accessible from other scripts.
    // Use sparingly - it exposes your internals to everything.
    public float moveSpeed = 10f;

    // PRIVATE: hidden from Inspector, only this script can access it.
    // This is the default if you don't write any modifier.
    private int currentLane = 1;

    // Same as above - no modifier means private.
    bool isJumping = false;

    // [SerializeField] PRIVATE: visible in Inspector but NOT
    // accessible from other scripts. THIS IS THE BEST PRACTICE.
    // You get Inspector editing without exposing your internals.
    [SerializeField] private float jumpForce = 8f;
    [SerializeField] private int maxLives = 3;
    [SerializeField] private GameObject obstaclePrefab;

    void Start()
    {
        // You can use all of these inside this script
        Debug.Log("Speed: " + moveSpeed);
        Debug.Log("Lane: " + currentLane);
        Debug.Log("Jump force: " + jumpForce);
    }
}

The key takeaway:

✅ Why [SerializeField] Is Better Than public

Making everything public is a common beginner habit because it's easy: public variables show up in the Inspector and can be accessed anywhere. But it means any other script can change your player's speed, lives, or anything else at any time, making bugs very hard to track down. [SerializeField] private gives you Inspector editing while keeping your variables protected. It's a small extra effort that prevents big headaches later.

Conditional Logic: if/else and switch

Conditional statements let your code make decisions. "If the player is in lane 0 and tries to move left, don't let them because there's no lane to the left."

Conditionals.csC#
using UnityEngine;

public class Conditionals : MonoBehaviour
{
    [SerializeField] private int lives = 3;
    [SerializeField] private int currentLane = 1;

    void Update()
    {
        // ----- IF / ELSE IF / ELSE -----

        // Simple if: runs the block only when the condition is true
        if (lives <= 0)
        {
            Debug.Log("Game Over!");
        }

        // if/else: one block runs if true, the other if false
        if (lives > 1)
        {
            Debug.Log("You have multiple lives.");
        }
        else
        {
            Debug.Log("Last life! Be careful!");
        }

        // if/else if/else: check multiple conditions in order
        if (currentLane == 0)
        {
            Debug.Log("Left lane");
        }
        else if (currentLane == 1)
        {
            Debug.Log("Center lane");
        }
        else if (currentLane == 2)
        {
            Debug.Log("Right lane");
        }
        else
        {
            Debug.LogError("Invalid lane: " + currentLane);
        }

        // ----- COMPARISON OPERATORS -----
        // ==  equals
        // !=  not equals
        // >   greater than
        // <   less than
        // >=  greater than or equal
        // <=  less than or equal

        // ----- LOGICAL OPERATORS -----
        // &&  AND (both must be true)
        // ||  OR (at least one must be true)
        // !   NOT (inverts true/false)

        bool isAlive = lives > 0;
        bool isInBounds = currentLane >= 0 && currentLane <= 2;

        if (isAlive && isInBounds)
        {
            // Player can keep running
        }
    }
}

Switch Statements

When you're checking one variable against many specific values, a switch statement is cleaner than a chain of if/else if:

SwitchExample.csC#
using UnityEngine;

// Define the enum so this script works on its own
public enum GameState { Menu, Playing, Paused, GameOver }

public class SwitchExample : MonoBehaviour
{
    [SerializeField] private GameState currentState = GameState.Menu;

    void Start()
    {
        HandleGameState(currentState);
    }

    void HandleGameState(GameState state)
    {
        switch (state)
        {
            case GameState.Menu:
                Debug.Log("Show main menu");
                break;
            case GameState.Playing:
                Debug.Log("Game is running");
                break;
            case GameState.Paused:
                Debug.Log("Game is paused");
                break;
            case GameState.GameOver:
                Debug.Log("Show game over screen");
                break;
            default:
                Debug.LogError("Unknown state: " + state);
                break;
        }
    }
}

Each case checks if the value matches. The break keyword exits the switch block. The default case runs if none of the other cases match.

Loops: for and foreach

Loops let you repeat code multiple times. You'll use them for iterating over collections of objects (all obstacles, all coins, etc.).

LoopsDemo.csC#
using UnityEngine;

public class LoopsDemo : MonoBehaviour
{
    void Start()
    {
        // ----- FOR LOOP -----
        // Runs a specific number of times.
        // i starts at 0, runs while i < 5, adds 1 each time.
        for (int i = 0; i < 5; i++)
        {
            Debug.Log("Spawning obstacle #" + i);
        }
        // Output: #0, #1, #2, #3, #4

        // ----- FOREACH LOOP -----
        // Iterates over every item in a collection.
        string[] laneNames = { "Left", "Center", "Right" };

        foreach (string lane in laneNames)
        {
            Debug.Log("Lane: " + lane);
        }
        // Output: Left, Center, Right

        // ----- WHILE LOOP -----
        // Runs as long as the condition is true.
        // Be careful: if the condition never becomes false,
        // you'll get an infinite loop that freezes Unity!
        int countdown = 3;
        while (countdown > 0)
        {
            Debug.Log("Countdown: " + countdown);
            countdown--; // subtract 1
        }
        // Output: 3, 2, 1
    }
}
⛔ Beware Infinite Loops

A while loop that never stops will freeze Unity completely. You'll have to force-quit the editor and lose any unsaved changes. Always make sure your while loops have a condition that will eventually become false. For this reason, for and foreach loops are usually safer — they have a built-in end point.

Methods (Functions)

A method is a named block of code that performs a specific task. Instead of writing the same code over and over, you put it in a method and call that method whenever you need it. Methods can also accept parameters (input values) and return a result.

MethodsDemo.csC#
using UnityEngine;

public class MethodsDemo : MonoBehaviour
{
    [SerializeField] private int score = 0;
    [SerializeField] private int lives = 3;

    void Start()
    {
        // Calling methods
        AddScore(100);
        AddScore(50);
        TakeDamage();

        bool alive = IsAlive();
        Debug.Log("Is alive: " + alive);

        float doubled = DoubleValue(21f);
        Debug.Log("Doubled: " + doubled); // 42
    }

    // A method with no return value (void) that takes a parameter
    void AddScore(int points)
    {
        score += points; // same as: score = score + points
        Debug.Log("Score is now: " + score);
    }

    // A method with no parameters
    void TakeDamage()
    {
        lives--;
        Debug.Log("Lives remaining: " + lives);

        if (lives <= 0)
        {
            Die();
        }
    }

    // A method that returns a bool (true/false)
    bool IsAlive()
    {
        return lives > 0;
    }

    // A method that takes a float and returns a float
    float DoubleValue(float input)
    {
        return input * 2f;
    }

    // A method that takes multiple parameters
    void SpawnObstacle(int lane, float zPosition)
    {
        Debug.Log("Spawning obstacle in lane " + lane
                  + " at z=" + zPosition);
    }

    void Die()
    {
        Debug.Log("Player died!");
    }
}

Breaking down the syntax: void AddScore(int points)

Classes and Objects

A class is a blueprint. An object is a thing built from that blueprint. Every C# script you write is a class. When you attach that script to a GameObject, Unity creates an instance (an object) of that class.

You can also create classes that are not MonoBehaviours — plain data classes that just hold information:

ObstacleData.csC#
// This class does NOT inherit from MonoBehaviour.
// It's just a container for data.
// It cannot be attached to a GameObject.

[System.Serializable] // Makes it visible in the Inspector
public class ObstacleData
{
    public string obstacleName;
    public int damageAmount;
    public float spawnChance;

    // Constructor: called when you create a new instance
    public ObstacleData(string name, int damage, float chance)
    {
        obstacleName = name;
        damageAmount = damage;
        spawnChance = chance;
    }
}
ObstacleSpawner.csC#
using UnityEngine;

public class ObstacleSpawner : MonoBehaviour
{
    void Start()
    {
        // Creating objects from the ObstacleData class
        ObstacleData barrier = new ObstacleData("Barrier", 1, 0.5f);
        ObstacleData wall = new ObstacleData("Wall", 1, 0.3f);

        Debug.Log(barrier.obstacleName + " does "
                  + barrier.damageAmount + " damage");
    }
}
💡 MonoBehaviour vs. Plain Classes

MonoBehaviour classes are scripts you attach to GameObjects. They have access to lifecycle methods (Start, Update), can reference transforms and other components, and live in the scene. Plain classes are just data or logic containers. They can't be attached to GameObjects, don't have Update/Start, but are useful for organizing data. We'll use both. For example, our PlayerController will be a MonoBehaviour (attached to the player), but our obstacle configuration data will be a plain class or ScriptableObject.

Arrays vs. Lists

Both arrays and lists store multiple values of the same type. The difference is that arrays have a fixed size (set when created) while lists can grow and shrink dynamically.

CollectionsDemo.csC#
using UnityEngine;
using System.Collections.Generic; // Required for List<T>

public class CollectionsDemo : MonoBehaviour
{
    void Start()
    {
        // ----- ARRAYS -----
        // Fixed size. Fast. Use when the count won't change.

        // Declare an array of 3 lane positions
        float[] lanePositions = new float[3];
        lanePositions[0] = -2.5f; // Left lane
        lanePositions[1] = 0f;    // Center lane
        lanePositions[2] = 2.5f;  // Right lane

        // Shorthand: declare and fill at the same time
        string[] laneNames = { "Left", "Center", "Right" };

        // Access by index (starts at 0!)
        Debug.Log("Center lane X: " + lanePositions[1]); // 0

        // Array length
        Debug.Log("Number of lanes: " + lanePositions.Length); // 3

        // Loop through an array
        for (int i = 0; i < laneNames.Length; i++)
        {
            Debug.Log("Lane " + i + ": " + laneNames[i]);
        }

        // ----- LISTS -----
        // Dynamic size. Slightly slower. Use when items are
        // added/removed at runtime.

        // Declare an empty list of GameObjects
        List<GameObject> activeObstacles = new List<GameObject>();

        // Add items
        // activeObstacles.Add(someObstacle);

        // Remove items
        // activeObstacles.Remove(someObstacle);
        // activeObstacles.RemoveAt(0); // remove first item

        // Check count
        Debug.Log("Active obstacles: " + activeObstacles.Count);

        // Check if list contains something
        // bool hasIt = activeObstacles.Contains(someObstacle);

        // Clear all items
        activeObstacles.Clear();

        // Lists also support foreach
        foreach (GameObject obstacle in activeObstacles)
        {
            // Do something with each obstacle
        }
    }
}

Rule of thumb: Use arrays when the collection has a fixed, known size (like lane positions — always 3). Use lists when items are added or removed during gameplay (like the currently active obstacles in the world).

⚠️ Index Out of Range

If you have an array with 3 elements, the valid indices are 0, 1, and 2. Trying to access index 3 (or higher) will crash your game with an IndexOutOfRangeException. This is one of the most common beginner errors. Always check that your index is within bounds: if (index >= 0 && index < array.Length).

Enums

An enum (short for "enumeration") is a custom type that defines a set of named constants. Instead of using cryptic numbers (0 = menu, 1 = playing, 2 = paused), you use readable names.

GameEnums.csC#
// Enums are usually defined outside of a class,
// or in their own file, so multiple scripts can use them.

public enum GameState
{
    Menu,
    Playing,
    Paused,
    GameOver
}

public enum Lane
{
    Left = 0,
    Center = 1,
    Right = 2
}

public enum PowerUpType
{
    SpeedBoost,
    Shield,
    Magnet,
    ScoreMultiplier
}
EnumUsage.csC#
using UnityEngine;

public class EnumUsage : MonoBehaviour
{
    private GameState currentState = GameState.Menu;
    private Lane currentLane = Lane.Center;

    void Update()
    {
        // Compare enum values
        if (currentState == GameState.Playing)
        {
            // Game logic only runs during gameplay
        }

        // Use in switch statements (very common pattern)
        switch (currentState)
        {
            case GameState.Menu:
                // Show menu UI
                break;
            case GameState.Playing:
                // Run game logic
                break;
            case GameState.Paused:
                // Freeze everything
                break;
            case GameState.GameOver:
                // Show results
                break;
        }
    }

    public void ChangeState(GameState newState)
    {
        Debug.Log("State changed from " + currentState
                  + " to " + newState);
        currentState = newState;
    }
}

Enums make your code much more readable. if (currentLane == Lane.Left) is immediately clear, while if (currentLane == 0) requires you to remember what 0 means. We'll use enums extensively for game states, lane identifiers, obstacle types, and more.

✅ Enums in the Inspector

When you expose an enum field in the Inspector (using public or [SerializeField]), Unity automatically creates a dropdown menu with all the enum values. This is a great user experience for designers who don't need to look at code. They just pick from a dropdown instead of remembering magic numbers.

Unity-Specific C# Patterns

Here are some C# patterns that are unique to Unity development. These will come up constantly in the remaining chapters.

GetComponent: Accessing Other Components

GetComponentDemo.csC#
using UnityEngine;

public class GetComponentDemo : MonoBehaviour
{
    private Rigidbody rb;
    private Collider col;

    void Awake()
    {
        // Get a component on the SAME GameObject
        rb = GetComponent<Rigidbody>();
        col = GetComponent<Collider>();

        // Always check if the component exists
        if (rb == null)
        {
            Debug.LogError("No Rigidbody found on " + gameObject.name);
        }
    }

    void FixedUpdate()
    {
        // Now you can use the Rigidbody
        rb.AddForce(Vector3.forward * 10f);
    }
}

Instantiate and Destroy: Creating and Removing Objects

SpawnDemo.csC#
using UnityEngine;

public class SpawnDemo : MonoBehaviour
{
    [SerializeField] private GameObject coinPrefab;

    void Start()
    {
        // Create a copy of the prefab at a specific position and rotation
        Vector3 spawnPos = new Vector3(0f, 1f, 10f);
        GameObject newCoin = Instantiate(coinPrefab, spawnPos, Quaternion.identity);

        // Destroy an object (removes it from the scene)
        Destroy(newCoin, 5f); // Destroy after 5 seconds
    }
}
💡 We'll Replace Instantiate/Destroy with Object Pooling

Instantiate() and Destroy() work, but they allocate and deallocate memory every time. In an infinite runner where obstacles and coins are constantly appearing and disappearing, this causes garbage collection spikes that make the game stutter. In Chapter 12, we'll build an Object Pool that recycles objects instead of creating and destroying them. For now, just know these methods exist.

Time.deltaTime: Frame-Rate Independent Movement

MovementDemo.csC#
using UnityEngine;

public class MovementDemo : MonoBehaviour
{
    [SerializeField] private float speed = 5f;

    void Update()
    {
        // WITHOUT deltaTime: moves 5 units per FRAME.
        // At 60fps = 300 units/sec. At 30fps = 150 units/sec.
        // The speed depends on frame rate! BAD.
        // transform.Translate(Vector3.forward * speed);

        // WITH deltaTime: moves 5 units per SECOND.
        // At 60fps: 5 * 0.0167 = 0.083 per frame, 60 * 0.083 = 5/sec
        // At 30fps: 5 * 0.0333 = 0.167 per frame, 30 * 0.167 = 5/sec
        // Same speed regardless of frame rate! GOOD.
        transform.Translate(Vector3.forward * speed * Time.deltaTime);
    }
}

Time.deltaTime is the time in seconds since the last frame. Multiplying any per-frame movement by Time.deltaTime makes it frame-rate independent. Always use Time.deltaTime with movement in Update(). This is one of the most important patterns in game development.

Debugging Your Code

Here's a truth about game development: you will spend more time debugging (finding and fixing problems) than writing new code. This is normal. Even experienced developers spend most of their time figuring out why something doesn't work the way they expected. The difference between a beginner and a professional is not that the professional writes bug-free code — it's that the professional knows how to find bugs quickly. This section teaches you the tools and techniques to do exactly that.

Debug.Log — Your Best Friend

The simplest and most frequently used debugging tool is Debug.Log(). It prints a message to the Unity Console. You can use it to verify that code is running, check the values of variables, and trace the flow of your program.

DebugLogExamples.csC#
using UnityEngine;

public class DebugLogExamples : MonoBehaviour
{
    [SerializeField] private float health = 100f;
    [SerializeField] private int score = 0;

    void Start()
    {
        // Basic message - prints white text to Console
        Debug.Log("Game started!");

        // Warning - prints yellow text
        Debug.LogWarning("Low memory - consider reducing quality settings.");

        // Error - prints red text
        Debug.LogError("Failed to load player data!");

        // Printing variable values to see what's happening
        Debug.Log("Player health: " + health);
        Debug.Log("Current score: " + score);

        // String interpolation (cleaner way to include variables)
        Debug.Log($"Player health: {health}, score: {score}");
        Debug.Log($"Player position: {transform.position}");

        // Tracking when methods are called and on which object
        Debug.Log("Start() was called on " + gameObject.name);
    }

    void Update()
    {
        // Be careful logging in Update - it prints EVERY FRAME
        // Only do this temporarily when tracking down a bug
        // Debug.Log($"Frame {Time.frameCount}: health = {health}");
    }

    void TakeDamage(float amount)
    {
        Debug.Log($"TakeDamage called with amount: {amount}");
        health -= amount;
        Debug.Log($"Health is now: {health}");

        if (health <= 0f)
        {
            Debug.Log("Player is dead!");
        }
    }
}
✅ Use String Interpolation ($"...")

Instead of concatenating strings with + signs ("Health: " + health + " Score: " + score), use string interpolation: $"Health: {health} Score: {score}". The $ before the opening quote lets you embed variables directly inside curly braces. It's cleaner, easier to read, and less error-prone.

Reading Error Messages

When something goes wrong in your code, Unity prints an error to the Console. These error messages have two critical parts: the message (what went wrong) and the stack trace (where it went wrong). Learning to read these will save you enormous amounts of time.

Here's an example of a real Unity error:

Console Error ExampleError
NullReferenceException: Object reference not set to an instance of an object
PlayerController.Update () (at Assets/_Project/Scripts/Player/PlayerController.cs:42)

Let's break this down piece by piece:

💡 Double-Click Errors to Jump to the Code

In the Unity Console, you can double-click on any error message and Unity will open your code editor and jump directly to the exact line that caused the error. This is the fastest way to find the problem. Get in the habit of doing this every time you see an error.

The Most Common Error: NullReferenceException

You will see this error more than any other. null means "nothing" — a variable that was supposed to point to an object, but doesn't point to anything. When you try to use a null variable (call a method on it, read a property from it), Unity throws a NullReferenceException.

The four most common causes of NullReferenceException in Unity:

  1. Forgot to assign a [SerializeField] reference in the Inspector. You declared a [SerializeField] private GameObject player; but never dragged a GameObject into the slot in the Inspector. The variable is still null.
    Fix: Select the GameObject with your script, find the empty slot in the Inspector, and drag the correct object into it.
  2. Called GetComponent<T>() but the component doesn't exist. You wrote GetComponent<Rigidbody>() but the GameObject doesn't actually have a Rigidbody attached.
    Fix: Make sure the component exists on the GameObject, or add a null check before using it.
  3. Tried to use an object that was Destroyed. You called Destroy(someObject) and then later tried to access someObject on a subsequent frame.
    Fix: Set the variable to null after destroying, and check for null before using it.
  4. Forgot to check if something exists before using it. A method like FindObjectOfType<T>() can return null if no matching object exists in the scene.
    Fix: Always check the return value before using it.

How to fix any NullReferenceException:

  1. Look at the line number in the error message.
  2. Go to that line in your code.
  3. Identify every variable being used on that line (anything before a . dot operator).
  4. Figure out which one is null — add Debug.Log() statements for each to check.
  5. Trace back to where that variable should have been assigned, and fix the assignment.
NullCheckExample.csC#
using UnityEngine;

public class NullCheckExample : MonoBehaviour
{
    // Drag an AudioSource component into this slot in the Inspector
    [SerializeField] private AudioSource audioSource;

    void Start()
    {
        // Try playing the sound when the game starts
        PlaySound();
    }

    void PlaySound()
    {
        // BAD: Will crash if audioSource is null
        // audioSource.Play();

        // GOOD: Check first, then use
        if (audioSource != null)
        {
            audioSource.Play();
        }
        else
        {
            Debug.LogWarning("AudioSource is not assigned on " + gameObject.name);
        }
    }
}

Visual Debugging

Sometimes you need to see what's happening in 3D space — where a raycast is pointing, what direction an object is facing, or where a spawn point is. Unity provides methods that draw lines and rays directly in the Scene View.

VisualDebugDemo.csC#
using UnityEngine;

public class VisualDebugDemo : MonoBehaviour
{
    void Update()
    {
        // Draw a red ray downward from this object (great for ground checks)
        Debug.DrawRay(transform.position, Vector3.down * 1.5f, Color.red);

        // Draw a green line between two points
        Debug.DrawLine(
            transform.position,
            transform.position + transform.forward * 5f,
            Color.green
        );

        // Draw a blue ray showing the object's forward direction
        Debug.DrawRay(transform.position, transform.forward * 3f, Color.blue);
    }
}

Important: These debug lines only appear in the Scene View, not in the Game View. You need to have the Scene View visible while the game is running to see them. They are extremely useful for visualizing things like ground-check raycasts, attack directions, and spawn zones.

Using Breakpoints (Visual Studio / VS Code)

Debug.Log is great for quick checks, but for complex bugs, breakpoints are far more powerful. A breakpoint is a marker you place on a line of code that says "stop execution here." When your code reaches that line, the entire game pauses, and you can inspect the value of every variable at that exact moment.

How to use breakpoints:

  1. Set a breakpoint: In Visual Studio or VS Code, click in the gutter (the left margin) next to a line number. A red dot appears — that's your breakpoint.
  2. Attach the debugger: In Visual Studio, click the "Attach to Unity" button in the toolbar (it looks like a play button with the Unity logo). In VS Code, use the "Attach to Unity" debug configuration.
  3. Play the game in Unity: Press Play as normal. When the code execution reaches your breakpoint, the game freezes and your IDE highlights the current line.
  4. Inspect variables: Hover over any variable to see its current value. The Locals and Watch windows show all variables in scope.
  5. Step through code: Press F10 (Step Over) to execute the current line and move to the next. Press F11 (Step Into) to dive into a method call. Press F5 to continue running until the next breakpoint.
  6. Remove the breakpoint: Click the red dot again to remove it. Press F5 or detach the debugger to let the game resume normally.
💡 When to Use Debug.Log vs. Breakpoints

Use Debug.Log for quick checks and when you need to see values over time (like tracking a variable every frame, or logging events as they happen in sequence). Use breakpoints when you need to understand the exact state of your program at a specific moment, when you have a complex bug that involves multiple variables and conditions, or when you can't figure out the problem from log messages alone. Breakpoints let you freeze time and examine everything, which is 10x more powerful for complex issues.

Coroutines

So far, every method we've written runs from start to finish in a single frame. But what if you want to do something over time? Wait 2 seconds, then spawn an enemy. Fade the screen out over half a second. Flash a power-up indicator three times with pauses in between. Normal methods can't do this — but coroutines can.

What Is a Coroutine?

A coroutine is a special kind of method that can pause its execution and resume later. While a normal method runs all its code in one frame, a coroutine can spread its work across multiple frames. It "yields" control back to Unity, lets the game continue running, and then picks up where it left off on a later frame (or after a specified delay).

Coroutines are perfect for:

Basic Syntax

A coroutine method looks different from a normal method in two ways: it returns IEnumerator instead of void, and it uses yield return statements to pause execution.

CoroutineBasics.csC#
using System.Collections;
using UnityEngine;

public class CoroutineBasics : MonoBehaviour
{
    // A coroutine method returns IEnumerator
    private IEnumerator WaitAndPrint()
    {
        Debug.Log("Starting...");

        // Pause execution for 2 seconds
        yield return new WaitForSeconds(2f);

        Debug.Log("2 seconds later!");

        // Pause for 1 more second
        yield return new WaitForSeconds(1f);

        Debug.Log("3 seconds total have passed!");
    }

    // Starting a coroutine (must be called from a MonoBehaviour)
    void Start()
    {
        // You don't call a coroutine like a normal method.
        // You must use StartCoroutine().
        StartCoroutine(WaitAndPrint());

        // This line runs IMMEDIATELY - it does NOT wait
        // for the coroutine to finish!
        Debug.Log("Start() method finished.");
    }
}

The output of the above code would be:

  1. "Starting..." (immediately)
  2. "Start() method finished." (immediately — Start doesn't wait for the coroutine)
  3. "2 seconds later!" (after 2 seconds)
  4. "3 seconds total have passed!" (after 3 seconds)

yield return Options

The yield return statement is what makes a coroutine pause. What you yield determines how long or until when it pauses:

YieldExamples.csC#
using System.Collections;
using UnityEngine;

public class YieldExamples : MonoBehaviour
{
    private int score = 0;
    private bool isPaused = false;

    void Start()
    {
        StartCoroutine(ShowYieldTypes());
    }

    private IEnumerator ShowYieldTypes()
    {
        Debug.Log("Starting yield examples...");

        // Wait for a specific number of seconds (real time)
        yield return new WaitForSeconds(2f);
        Debug.Log("2 seconds passed!");

        // Wait one frame (continues on the next Update cycle)
        yield return null;
        Debug.Log("One frame later.");

        // Wait until the end of the current frame
        yield return new WaitForEndOfFrame();

        // Wait for the next FixedUpdate (physics step)
        yield return new WaitForFixedUpdate();

        // Wait until a condition becomes true
        // yield return new WaitUntil(() => score >= 100);

        // Wait while a condition is true (opposite of WaitUntil)
        // yield return new WaitWhile(() => isPaused);

        Debug.Log("All yield examples done!");
    }
}

Practical Example: Fade Out Over Time

Here's a real-world example — smoothly fading out a UI element over a specified duration. This pattern comes up constantly in game development.

FadeExample.csC#
using System.Collections;
using UnityEngine;

public class FadeExample : MonoBehaviour
{
    [SerializeField] private CanvasGroup canvasGroup;

    private IEnumerator FadeOut(CanvasGroup group, float duration)
    {
        float elapsed = 0f;
        float startAlpha = group.alpha;

        while (elapsed < duration)
        {
            elapsed += Time.deltaTime;
            group.alpha = Mathf.Lerp(startAlpha, 0f, elapsed / duration);
            yield return null; // Wait one frame, then continue the loop
        }

        group.alpha = 0f; // Ensure we end at exactly 0
    }

    // Call this to start fading
    public void StartFadeOut()
    {
        StartCoroutine(FadeOut(canvasGroup, 1.5f)); // Fade over 1.5 seconds
    }
}

This coroutine runs a while loop that executes once per frame (yield return null). Each frame, it calculates how far through the fade we are and updates the alpha (transparency) value. After the specified duration, the loop ends and the alpha is set to exactly 0.

Stopping Coroutines

Sometimes you need to cancel a coroutine before it finishes. For example, if a player picks up a power-up and then picks up another one, you might want to stop the first timer and start a new one. To stop a coroutine, you need to store a reference to it.

StopCoroutineExample.csC#
using System.Collections;
using UnityEngine;

public class StopCoroutineExample : MonoBehaviour
{
    [SerializeField] private CanvasGroup canvasGroup;
    private Coroutine fadeCoroutine;

    public void StartFading()
    {
        // Stop any existing fade before starting a new one
        if (fadeCoroutine != null)
        {
            StopCoroutine(fadeCoroutine);
        }

        // Store the reference so we can stop it later
        fadeCoroutine = StartCoroutine(FadeOut(canvasGroup, 1f));
    }

    public void CancelFading()
    {
        if (fadeCoroutine != null)
        {
            StopCoroutine(fadeCoroutine);
            fadeCoroutine = null;
        }
    }

    private IEnumerator FadeOut(CanvasGroup group, float duration)
    {
        float elapsed = 0f;
        float startAlpha = group.alpha;

        while (elapsed < duration)
        {
            elapsed += Time.deltaTime;
            group.alpha = Mathf.Lerp(startAlpha, 0f, elapsed / duration);
            yield return null;
        }

        group.alpha = 0f;
        fadeCoroutine = null; // Clear reference when done
    }
}

When to Use Coroutines vs. Update

Coroutines and Update() can sometimes accomplish the same thing, but each has strengths. Here's a guide for when to use which:

Use Coroutines For Use Update For
One-time delayed actions Continuous every-frame logic
Sequences (do A, wait, do B, wait, do C) Player input handling
Temporary timed effects (power-up duration) Movement and physics
Loading and initialization sequences Continuous state checks
Smooth transitions (fades, lerps over time) Real-time game logic that never stops
✅ Coroutines in Later Chapters

We'll use coroutines extensively as the project grows: power-up timers in Chapter 16, UI animations and screen transitions in Chapter 19, and audio crossfading in Chapter 20. Understanding the basics now — how to start them, stop them, and what yield return does — will save you a lot of confusion when we reach those chapters. If this section felt abstract, don't worry. It will click once you use coroutines in a real gameplay system.

Chapter Summary

That was a lot of information. Here's a recap of everything we covered:

You don't need to memorize all of this. As we build each system in the coming chapters, you'll see these concepts used in context and they'll become natural. This chapter is meant as a reference you can come back to whenever you encounter something unfamiliar.

In the next chapter, we'll design the architecture and folder structure for our project, setting up the organizational foundation that will keep our code clean as it grows.