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.
- In Unity, right-click in the Project window on the
Assetsfolder. - Choose Create → C# Script.
- Name it
MyFirstScript. The name is important — it must match the class name inside the file exactly (including capitalization). - Double-click the script to open it in your code editor.
You'll see this code:
using UnityEngine;
public class MyFirstScript : MonoBehaviour
{
void Start()
{
}
void Update()
{
}
}
Let's break down every single piece:
using UnityEngine;— This line imports Unity's core library so you can use things likeMonoBehaviour,Debug,Vector3,Transform, etc. You'll have this line at the top of every Unity script.public class MyFirstScript : MonoBehaviour— This declares a class namedMyFirstScriptthat inherits fromMonoBehaviour. MonoBehaviour is Unity's base class for any script that attaches to a GameObject. The: MonoBehaviourpart gives your script access to all of Unity's built-in functions likeStart(),Update(), and the ability to be attached to GameObjects.void Start()— A method that Unity calls once, when the GameObject this script is attached to first becomes active. Use it for initialization.void Update()— A method that Unity calls every single frame. If your game runs at 60 fps, this method is called 60 times per second. Use it for things that need to happen continuously, like checking for input or moving objects.
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:
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.
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.
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) |
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.
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:
- Use
publiconly when other scripts genuinely need to access this variable. - Use
[SerializeField] privatewhen you want to edit a value in the Inspector but keep it private to this script. This is the pattern we'll use most in this course. - Use
private(or no modifier) for internal state that should never be edited from outside or from the Inspector.
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."
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:
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.).
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
}
}
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.
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)
void= the return type.voidmeans "this method doesn't return anything."AddScore= the method name. Use descriptive verbs.(int points)= the parameter list. This method requires an integer input calledpoints.
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:
// 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;
}
}
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 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.
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).
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.
// 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
}
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.
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
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
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
}
}
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
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.
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!");
}
}
}
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:
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:
NullReferenceException— The type of error. This specific one means you tried to use a variable that isnull(points to nothing). This is the single most common error in Unity.Object reference not set to an instance of an object— A human-readable description of the error. Translation: "You tried to use something that doesn't exist."PlayerController.Update()— The error occurred inside theUpdatemethod of thePlayerControllerclass.Assets/_Project/Scripts/Player/PlayerController.cs:42— The exact file and line number. The error is on line 42 ofPlayerController.cs.
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:
- 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. - Called
GetComponent<T>()but the component doesn't exist. You wroteGetComponent<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. - Tried to use an object that was Destroyed. You called
Destroy(someObject)and then later tried to accesssomeObjecton a subsequent frame.
Fix: Set the variable to null after destroying, and check for null before using it. - 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:
- Look at the line number in the error message.
- Go to that line in your code.
- Identify every variable being used on that line (anything before a
.dot operator). - Figure out which one is null — add
Debug.Log()statements for each to check. - Trace back to where that variable should have been assigned, and fix the assignment.
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.
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:
- 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.
- 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.
- 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.
- Inspect variables: Hover over any variable to see its current value. The Locals and Watch windows show all variables in scope.
- 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.
- Remove the breakpoint: Click the red dot again to remove it. Press F5 or detach the debugger to let the game resume normally.
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:
- Delays: Wait 3 seconds, then do something.
- Animations over time: Smoothly fade out an object's opacity over 1 second.
- Sequences: Do A, wait, do B, wait, do C.
- Timed events: A power-up lasts for 10 seconds, then expires.
- Loading sequences: Show a progress bar while loading resources.
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.
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:
"Starting..."(immediately)"Start() method finished."(immediately — Start doesn't wait for the coroutine)"2 seconds later!"(after 2 seconds)"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:
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.
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.
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 |
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:
- Scripts are C# classes that inherit from
MonoBehaviourand attach to GameObjects. - Lifecycle methods:
Awake(),Start(),Update(),FixedUpdate(),LateUpdate(),OnEnable(),OnDisable(),OnDestroy(). - Variables:
int,float(withf!),bool,string,Vector3,Vector2. - Access modifiers:
public,private,[SerializeField] private(preferred). - Conditionals:
if/else,switch, comparison and logical operators. - Loops:
for,foreach,while(careful with infinite loops!). - Methods: reusable blocks of code with optional parameters and return values.
- Classes: blueprints for objects. MonoBehaviour classes attach to GameObjects; plain classes hold data.
- Collections:
arrays(fixed size),List<T>(dynamic size). - Enums: named constants for readable code (GameState, Lane, PowerUpType).
- Unity patterns:
GetComponent,Instantiate,Destroy,Time.deltaTime. - Debugging:
Debug.Log/LogWarning/LogError, reading error messages, fixing NullReferenceException, visual debugging withDebug.DrawRay, and using breakpoints in your IDE. - Coroutines: methods that pause and resume over time using
IEnumerator,yield return,StartCoroutine, andStopCoroutine.
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.