Unity Mastery Handbook
Topic chapterT03P1 · Foundations: Editor, C#, Maths, Physics, 2D/3D

C# Fundamentals: Types, Logic, Methods & Classes

Learn the core of the C# language the way a Unity programmer uses it: values and types, decisions and loops, arrays and lists, methods, and your first classes, with every example compiled and run, and ending with a complete turn-based duel you build yourself.

Study time
10–14 hours
Reading time
163 min read · 10,643 words
Prerequisites
T01 · Setting Up: Unity 6, Hub, IDE, Packages & Version Control
Courses merged
7 course files
Your progress
—
After this chapter you can…
  • Set up a fast C# playground and read a small program line by line, including what the compiler and Unity do with it
  • Choose the right type for a value, and predict the results of integer and floating-point maths, casts and rounding
  • Write decisions with if, switch and patterns, and repeat work with for, while and foreach
  • Store collections of values in arrays, 2D arrays and lists, and avoid the off-by-one and shared-array traps
  • Write methods with parameters and return values, overloads, optional parameters, ref and out
  • Design small classes with private fields, properties, constructors, static members and enums
  • Explain the difference between value types and reference types, and what null and a NullReferenceException are
  • Read compiler errors and exceptions, and know which C# features Unity 6 supports

Every behaviour in a Unity game is written in C#. Before you can make a door open or an enemy chase you, you need the language itself: how to store a value, make a decision, repeat something, and group code into methods and classes. This chapter teaches that core, without Unity getting in the way, and then shows how each piece carries straight into a Unity script.

Most examples here are small console programs: you run them and they print text. That's deliberate. A console program starts in a second, has no scene or Inspector to set up, and shows you exactly what your code did. Everything you learn applies unchanged inside Unity, and the last sections show you how. Every program in this chapter has been compiled and run, and its output is shown underneath, so you can check yours against it.

The big picture#

A program is a list of instructions that work on data. Almost everything in this chapter is one of four ideas:

  • Values and variables: numbers, text and true/false values, stored under names so you can use them later.
  • Control flow: choosing which instructions run (decisions) and running some many times (loops).
  • Methods: a named block of instructions you can run whenever you need it, with inputs and an output.
  • Classes: your own types, bundling data with the methods that work on it. A Unity component is a class.

How C# code gets to run#

You write C# as text in .cs files. The computer can't run that text directly, so it goes through two translations:

.cs files what you write C# compiler checks and translates assembly (.dll) intermediate language Mono translated while the game runs IL2CPP to C++, then machine code when you build Errors here are compile errors: nothing runs until they're fixed. The Editor always uses Mono.
The compiler turns your text into an assembly. Unity then runs it with Mono (in the Editor and in Mono builds) or converts it ahead of time with IL2CPP (required on iOS, the Web and consoles). The language you write is the same either way.

Two consequences matter right away:

  • The compiler checks your code before anything runs. If you misspell a name or put text where a number should go, you get a compile error and nothing runs at all. In Unity, one compile error stops every script in the project from updating, which is why the Console's first red error is always the one to fix.
  • Unity decides which version of C# you get. Unity 6 compiles C# 9 and gives you the .NET Standard 2.1 library. Some features from newer C# books won't compile in Unity. A section near the end lists them.

A C# playground#

Learning a language goes much faster when trying something takes seconds. Unity is slow for that: every change recompiles the project and you need a scene to see anything. So set up a console project once, and use it for everything in this chapter.

Creating a console project#

You installed the .NET SDK with your IDE in T01 (Rider and Visual Studio include it; for VS Code, install it from Microsoft's .NET download page). Open a terminal in a folder for your experiments and run:

Make and run a console projectShell
dotnet new console -n Playground
cd Playground
dotnet run

It prints Hello, World!. Open the folder in your IDE. To make the compiler accept only what Unity accepts, open Playground.csproj and add a LangVersion line inside the PropertyGroup:

Playground.csproj (the line to add is highlighted)XML
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>9.0</LangVersion>
  </PropertyGroup>
</Project>

Your TargetFramework may show a different number; leave it. With LangVersion set to 9.0, if you use a feature Unity doesn't have, you'll get an error here first.

Your first program, line by line#

The template gives you a one-line program. Replace Program.cs with this longer form, which is how every example in this handbook is written:

Hello/Program.cs.NET consoleC#
using System;

namespace Hello
{
    class Program
    {
        static void Main()
        {
            // Everything after two slashes is a comment. The compiler ignores it.
            Console.WriteLine("Hello from C#!");
            Console.WriteLine("Two plus three is " + (2 + 3));
        }
    }
}
OutputText
Hello from C#!
Two plus three is 5
  • using System; lets you write Console instead of its full name, System.Console. using lines bring a namespace (a group of related types) into view.
  • namespace Hello puts your own code in a namespace, so its names can't clash with anyone else's.
  • class Program is a class. All C# code lives inside a class or a similar type; you'll write your own later.
  • static void Main() is the entry point: the method .NET calls when the program starts. Unity scripts don't have one, because Unity calls your methods itself.
  • Braces { } group lines into a block. Every opening brace has a closing one, and your IDE highlights the pair.
  • Statements end with a semicolon. Console.WriteLine(...) is a statement that prints a line.
  • Comments start with // and run to the end of the line, or sit between /* and */. Write them to explain why, not to repeat what the code says.

C# is case-sensitive: console.writeline is an error, and score and Score are two different names. Spaces and line breaks mostly don't matter to the compiler, but they matter to people, so follow the layout you see here.

Variables and types#

A variable is a named place to keep a value. You declare it with a type and a name, and usually give it a starting value:

Declaring variablesC#
int lives = 3;          // type, name, = starting value;
lives = lives - 1;      // assign a new value later
int coins;              // declared without a value...
coins = 10;             // ...must be assigned before it's read

The type says what kind of value the variable holds, and it never changes. That's called static typing, and it's a feature: the compiler can tell you that lives = "three" is a mistake before you run anything.

The types you'll use most#

TypeHoldsExampleNotes
intWhole numbers, about ±2.1 billionint score = 1250;The default for counting: lives, ammo, indexes
floatNumbers with a fraction, about 7 digits of precisionfloat speed = 4.5f;What Unity uses for positions, time and physics. Note the f
doubleFractions, about 15 to 16 digitsdouble d = 1234.5678;The default for a literal like 1.5. Rare in Unity code
booltrue or falsebool hasKey = false;The result of every comparison
stringTextstring name = "Robin";Double quotes
charOne characterchar grade = 'B';Single quotes
longWhole numbers, about ±9.2 × 1018long total = 3_000_000_000;When int isn't big enough. The underscores are only for reading
byteWhole numbers 0 to 255byte alpha = 255;Colour channels, compact data
decimalExact decimal fractions, 28 digitsdecimal price = 4.99m;Money in business software. Rare in games

The letter after a number is a suffix that sets its type: 4.5f is a float, 4.5 is a double, 4.5m is a decimal, 3L is a long. Forget the f and float speed = 4.5; is a compile error, because a double doesn't fit in a float without losing precision. In Unity you'll type that f thousands of times.

var, const, and naming#

  • var lets the compiler work out the type from the value: var level = 7; is exactly int level = 7;. The variable is still an int forever. Use var when the type is obvious from the right-hand side, and write the type when it isn't.
  • const marks a value that never changes and is known when you compile: const int StartingLives = 3;. The compiler stops anyone from changing it, and the name tells readers what 3 means.
  • Names: local variables are camelCase (runSpeed), and constants, methods, classes and properties are PascalCase (StartingLives). Use whole words. t, tmp and x2 save a second of typing and cost minutes of reading.
Variables/Program.cs.NET consoleC#
using System;

namespace Variables
{
    class Program
    {
        const int StartingLives = 3;

        static void Main()
        {
            string playerName = "Robin";
            int lives = StartingLives;
            float runSpeed = 4.5f;
            double distanceRun = 1234.5678;
            bool hasKey = false;
            char grade = 'B';
            long coinsInBank = 3_000_000_000;
            var level = 7;   // the compiler works out that level is an int

            Console.WriteLine($"{playerName} has {lives} lives and runs at {runSpeed} m/s.");
            Console.WriteLine($"Distance run: {distanceRun:F1} m. Key: {hasKey}. Grade: {grade}.");
            Console.WriteLine($"Coins in the bank: {coinsInBank:N0}. Level {level}.");

            lives = lives - 1;
            hasKey = true;
            level++;
            Console.WriteLine($"After the trap: {lives} lives, key: {hasKey}, level {level}.");
        }
    }
}
OutputText
Robin has 3 lives and runs at 4.5 m/s.
Distance run: 1234.6 m. Key: False. Grade: B.
Coins in the bank: 3,000,000,000. Level 7.
After the trap: 2 lives, key: True, level 8.

The $"..." strings with values in braces are interpolated strings, covered below. level++ adds one to level.

Scope: where a variable exists#

A variable exists from where it's declared to the end of the block (the { }) it's declared in. That's its scope. Declare a variable inside an if block and it's gone after the closing brace; trying to use it later is a compile error ("The name 'x' does not exist in the current context"). Declare variables in the smallest scope that works, as close as possible to where they're used. The fewer lines a variable is visible from, the fewer places can change it by mistake.

Numbers and maths#

Maths in code mostly works the way you expect, with a few rules that cause a surprising share of beginner bugs. This program shows all of them:

Numbers/Program.cs.NET consoleC#
using System;

namespace Numbers
{
    class Program
    {
        static void Main()
        {
            // Dividing two ints gives an int: the fraction is thrown away.
            Console.WriteLine(7 / 2);
            Console.WriteLine(7 % 2);
            Console.WriteLine(7 / 2f);
            Console.WriteLine((float)7 / 2);

            // An int that goes past its largest value wraps round to the smallest.
            int counter = int.MaxValue;
            counter = counter + 1;
            Console.WriteLine(counter);

            // 0.1 and 0.2 can't be stored exactly, so their sum isn't exactly 0.3.
            double sum = 0.1 + 0.2;
            Console.WriteLine(sum == 0.3);
            Console.WriteLine(sum.ToString("G17"));
            Console.WriteLine(Math.Abs(sum - 0.3) < 0.000001);

            // Casting to int cuts off the fraction. Rounding is a separate step.
            float health = 7.9f;
            Console.WriteLine((int)health);
            Console.WriteLine((int)Math.Round(health));
            Console.WriteLine(Math.Round(2.5));
            Console.WriteLine(Math.Round(3.5));

            // Dividing a float by zero doesn't crash; dividing an int by zero does.
            float speed = 10f;
            Console.WriteLine(float.IsInfinity(speed / 0f));
        }
    }
}
OutputText
3
1
3.5
3.5
-2147483648
False
0.30000000000000004
True
7
8
2
4
True

Integer division and remainder#

When both sides of / are integers, the result is an integer and the fraction is thrown away (not rounded): 7 / 2 is 3, and -7 / 2 is -3. If either side is a float, the maths is done in floats: 7 / 2f is 3.5. The classic bug is computing a percentage: collected / total * 100 with two ints is 0 until the very end. Write collected * 100 / total, or make one side a float.

The remainder operator % gives what's left over: 7 % 2 is 1. It's more useful than it looks: n % 2 == 0 tests for even numbers, i % 3 == 0 does something every third time, and (index + 1) % count steps through a list and wraps back to the start, which you'll use in the guided build.

Overflow#

Every integer type has a range. Go past the top and the value wraps around to the bottom, silently: int.MaxValue + 1 is -2147483648. In a game that's a score that suddenly goes negative, or a timer counted in milliseconds that breaks after 24 days. Use long for values that can grow without limit. If you want an error instead of a silent wrap, wrap the maths in checked( … ), and C# throws an OverflowException.

Floating-point numbers aren't exact#

float and double store numbers in binary, and most decimal fractions (0.1, 0.2, 0.3…) have no exact binary form, just as 1/3 has no exact decimal form. The tiny errors are usually invisible, but they mean you should never compare floats with ==. Compare against a small tolerance instead:

Comparing floatsC#
bool arrived = Math.Abs(distance - target) < 0.001f;   // plain C#
bool arrived2 = Mathf.Approximately(distance, target);  // Unity's helper

Floats also lose precision as numbers get big. A float keeps about seven significant digits, so at 100,000 units from the origin, positions can only change in steps of about 0.008. That's why huge open worlds jitter far from the centre and use tricks like moving the world back to the origin. Keep your levels within a few thousand units of (0, 0, 0) and you won't notice.

Converting between number types#

  • Widening conversions happen on their own because nothing is lost: int to long, int to float, float to double.
  • Narrowing conversions need a cast, written (type)value, because you might lose something: (int)7.9f is 7. The cast cuts off the fraction; it doesn't round.
  • Rounding is a separate step: Math.Round, or in Unity Mathf.Round, Mathf.RoundToInt, Mathf.FloorToInt and Mathf.CeilToInt.

Math and Mathf#

.NET's Math class works mostly on double. Unity's Mathf has the same functions for float, plus game-specific ones: Mathf.Clamp, Mathf.Lerp, Mathf.MoveTowards, Mathf.PingPong, Mathf.SmoothDamp. In Unity code, use Mathf so you don't have to cast back to float everywhere. T06 · Game Maths: Vectors, Trigonometry, Matrices, Quaternions & Interpolation uses most of them.

Strings and text#

A string is a sequence of characters. You'll use strings for names, messages, dialogue, save data and, constantly, for logging what your code is doing.

Strings/Program.cs.NET consoleC#
using System;

namespace Strings
{
    class Program
    {
        static void Main()
        {
            string name = "Ada";
            int score = 1250;
            float accuracy = 0.8734f;
            float bestTime = 83.456f;

            // Joining with + works, but interpolation ($"...") is easier to read.
            Console.WriteLine("Player " + name + " scored " + score);
            Console.WriteLine($"Player {name} scored {score}");

            // A colon inside the braces adds a format; a comma sets a width.
            Console.WriteLine($"Accuracy {accuracy * 100:F1}%, best lap {bestTime:F2} s");
            Console.WriteLine($"[{name,-6}] [{score,6}] [{score:D6}]");

            // Strings have many useful methods. None of them change the original string.
            string title = "  The Lighthouse Keeper  ";
            string clean = title.Trim();
            Console.WriteLine($"'{clean}' has {clean.Length} characters");
            Console.WriteLine(clean.ToUpper());
            Console.WriteLine(clean.Contains("House"));
            Console.WriteLine(clean.Replace("Keeper", "Cat"));
            Console.WriteLine(clean.Substring(4, 10));

            string loot = "rope,lamp,map";
            string[] items = loot.Split(',');
            Console.WriteLine($"{items.Length} items; the second is {items[1]}");
            Console.WriteLine(string.Join(" | ", items));

            // Escape sequences, and verbatim strings for paths.
            Console.WriteLine("She said \"run!\"\nSo we ran.");
            Console.WriteLine(@"C:\Games\Island\save.json");

            // == compares the text. Case matters unless you say otherwise.
            string typed = "POTION";
            Console.WriteLine(typed == "potion");
            Console.WriteLine(string.Equals(typed, "potion", StringComparison.OrdinalIgnoreCase));
            Console.WriteLine(string.IsNullOrEmpty(""));
        }
    }
}
OutputText
Player Ada scored 1250
Player Ada scored 1250
Accuracy 87.3%, best lap 83.46 s
[Ada   ] [  1250] [001250]
'The Lighthouse Keeper' has 21 characters
THE LIGHTHOUSE KEEPER
False
The Lighthouse Cat
Lighthouse
3 items; the second is lamp
rope | lamp | map
She said "run!"
So we ran.
C:\Games\Island\save.json
False
True
True

Interpolation and formatting#

Put a $ before the opening quote, and anything in { } is worked out and inserted. After a colon you can add a format:

FormatExampleGives
F2{83.456f:F2}83.46: two decimal places
N0{3000000:N0}3,000,000: thousands separators
D6{1250:D6}001250: pad an int with zeros
,6 / ,-6{score,6}Right- or left-align in 6 characters, for tables
X2{255:X2}FF: hexadecimal, for colour codes

Working with strings#

  • Length: name.Length is the number of characters. Characters are numbered from 0, so name[0] is the first one, a char.
  • Searching: Contains, StartsWith, EndsWith, IndexOf (the position, or -1 if it isn't there).
  • Changing: ToUpper, ToLower, Trim, Replace, Substring(start, length), PadLeft.
  • Splitting and joining: Split(',') gives an array of pieces; string.Join(", ", pieces) puts them back together.
  • Empty or missing: string.IsNullOrEmpty(s) and string.IsNullOrWhiteSpace(s).

Strings are immutable: no method changes a string. clean.ToUpper() returns a new string and leaves clean alone. Writing name.Trim(); on its own line does nothing useful; you need name = name.Trim();.

Special characters are written with a backslash: \n is a new line, \t a tab, \" a double quote, \\ a backslash. A verbatim string, with @ before the quote, treats backslashes as ordinary characters, which is easier for Windows paths.

Operators#

KindOperatorsExample
Arithmetic+ - * / %damage * 2 - armour
Assignment= += -= *= /= %=score += 10; means score = score + 10;
Increment++ --lives--;
Comparison== != < > <= >=health <= 0 gives a bool
Logical&& || !hasKey && !isLocked
Conditional? :isNight ? "stars" : "sun"
Null?? ??= ?.name ?? "Unknown"

Combining conditions#

&& (and) is true only if both sides are true; || (or) is true if either is; ! (not) flips true and false. Both && and || short-circuit: they stop as soon as the answer is known. In enemy != null && enemy.IsAlive, if enemy is null the right side never runs, so it can't crash. Put the cheap or protective check on the left.

Precedence decides what happens first when you mix operators: * and / before + and -, comparisons before &&, and && before ||. You don't need to memorise the table. When in doubt, add parentheses: (a || b) && c says what you mean to the compiler and to the next reader.

Increment and compound assignment#

x++ adds one, and x += 5 adds five. On its own line, x++ and ++x do the same thing. They differ only inside a bigger expression: x++ gives the old value and then adds, ++x adds and then gives the new value. Code that depends on that difference is hard to read, so keep ++ on its own line.

The null operators#

null means "no object" (more below). Three operators help with it: a ?? b gives a unless it's null, in which case it gives b; a ??= b assigns b to a only if a is null; and a?.Name gives a.Name, or null if a is null, instead of crashing.

Bitwise operators, briefly#

&, |, ^, ~, << and >> work on the individual bits of an integer. You'll meet them first in Unity's layer masks, where each of the 32 layers is one bit: 1 << 8 is a mask for layer 8 only, and mask1 | mask2 combines two masks. T08 · 3D Physics: Rigidbodies, Colliders, Raycasts & Character Controllers uses them for raycasts, and T04 · C# Beyond Basics: OOP, Generics, Collections, LINQ, Delegates & Memory covers flags enums built the same way.

Decisions#

Decisions/Program.cs.NET consoleC#
using System;

namespace Decisions
{
    class Program
    {
        static void Main()
        {
            int health = 20;
            int maxHealth = 100;
            bool hasShield = true;
            bool isNight = false;

            // if / else if / else: the first true condition wins, the rest are skipped.
            if (health <= 0)
            {
                Console.WriteLine("Defeated");
            }
            else if (health < maxHealth / 4)
            {
                Console.WriteLine("Badly hurt");
            }
            else
            {
                Console.WriteLine("Still standing");
            }

            // && and || combine conditions.
            if (health < 50 && !hasShield)
            {
                Console.WriteLine("Run!");
            }
            else if (health < 50 || isNight)
            {
                Console.WriteLine("Careful...");
            }

            // The conditional operator picks one of two values.
            string sky = isNight ? "stars" : "sun";
            Console.WriteLine($"Look up: {sky}");

            // switch statement: compare one value against several cases.
            string weapon = "axe";
            int damage;
            switch (weapon)
            {
                case "dagger":
                    damage = 4;
                    break;
                case "sword":
                case "axe":
                    damage = 7;
                    break;
                default:
                    damage = 1;
                    break;
            }
            Console.WriteLine($"The {weapon} hits for {damage}");

            // switch expression with patterns: shorter, and it produces a value.
            int score = 3200;
            string rank = score switch
            {
                < 0 => "invalid",
                < 1000 => "Bronze",
                >= 1000 and < 5000 => "Silver",
                _ => "Gold",
            };
            Console.WriteLine($"{score} points: {rank}");

            object reward = 25;
            if (reward is int coins and > 10)
            {
                Console.WriteLine($"A big reward: {coins} coins");
            }
        }
    }
}
OutputText
Badly hurt
Careful...
Look up: sun
The axe hits for 7
3200 points: Silver
A big reward: 25 coins

if, else if, else#

An if runs its block only when its condition is true. Add else if branches to test more conditions in order, and a final else for "none of the above". Only the first branch whose condition is true runs, so order matters: test the most specific case first. With health at 20, both "health < 25" and "not defeated" are true, and only the first one that's written wins.

Always use braces, even for one line. Without them, only the next statement belongs to the if, and the day someone adds a second line it silently runs every time.

Guard clauses instead of nesting#

Deeply nested ifs are hard to follow. Turn them inside out: check for the cases where there's nothing to do, and leave early.

Nested, then with guard clausesC#
// Hard to read: the real work is three levels deep.
void Open(Door door)
{
    if (door != null)
    {
        if (!door.IsLocked)
        {
            if (!door.IsOpen)
            {
                door.Swing();
            }
        }
    }
}

// Easier: deal with each reason to stop, then do the work.
void OpenWithGuards(Door door)
{
    if (door == null) return;
    if (door.IsLocked) return;
    if (door.IsOpen) return;

    door.Swing();
}

One-line if (…) return; guards are the common exception to "always use braces": they're short, they're at the top, and they leave immediately.

switch statements and switch expressions#

When you compare one value against many possibilities, switch is clearer than a long else if chain. In a switch statement, each case ends with break (or return); several labels can share one block, as "sword" and "axe" do; and default catches everything else. Unlike some languages, C# won't let one case fall through into the next by accident.

A switch expression (the form with =>) produces a value, which makes it ideal for "pick one of these". Each line is a pattern:

  • A constant: "axe" => 7.
  • A relational pattern: < 1000 => "Bronze".
  • Patterns combined with and, or and not: >= 1000 and < 5000.
  • The discard _, which matches anything: the "default" case.

Arms are tried top to bottom, and the first match wins. If no arm matches, the program throws an exception, so always end with _ unless you've covered every case.

The is operator tests a value against a pattern too: reward is int coins and > 10 checks that reward holds an int bigger than 10 and, if so, gives you that int as coins. You'll use is mostly to check what type something is, in T04 · C# Beyond Basics: OOP, Generics, Collections, LINQ, Delegates & Memory.

Loops#

Loops repeat a block. Choosing the right one is about what you know up front:

  • for: you're counting (do this 10 times; go through indexes 0 to 9).
  • foreach: you want every item in a collection and don't need the index.
  • while: you repeat until something becomes false, and don't know how many times that will take.
  • do … while: like while, but the body always runs at least once.
for (int wave = 1; wave <= 5; wave++) { total += wave * 10; } 1. initialise int wave = 1 2. check wave <= 5 true 3. body total += wave * 10 4. step: wave++ false: leave the loop after the loop
The three parts of a for header run at different times: the initialiser once, the check before every pass, and the step after every pass. The body runs five times here, with wave going 1, 2, 3, 4, 5.
Loops/Program.cs.NET consoleC#
using System;

namespace Loops
{
    class Program
    {
        static void Main()
        {
            // while: repeat as long as the condition is true.
            int fuel = 3;
            while (fuel > 0)
            {
                Console.WriteLine($"Burning... fuel left: {fuel}");
                fuel--;
            }

            // for: start; keep going while; step. The classic counting loop.
            int total = 0;
            for (int wave = 1; wave <= 5; wave++)
            {
                total += wave * 10;
            }
            Console.WriteLine($"Enemies over 5 waves: {total}");

            // do-while: the body always runs at least once.
            int attempts = 0;
            do
            {
                attempts++;
            } while (attempts * attempts < 20);
            Console.WriteLine($"Attempts: {attempts}");

            // foreach: visit every item. A string is a sequence of chars.
            int vowels = 0;
            foreach (char letter in "lighthouse")
            {
                if ("aeiou".IndexOf(letter) >= 0)
                {
                    vowels++;
                }
            }
            Console.WriteLine($"Vowels in lighthouse: {vowels}");

            // continue skips to the next turn; break leaves the loop.
            string picked = "";
            for (int i = 1; i <= 10; i++)
            {
                if (i % 3 == 0)
                {
                    continue;
                }
                if (i > 7)
                {
                    break;
                }
                picked += i + " ";
            }
            Console.WriteLine($"Picked: {picked.Trim()}");

            // Nested loops: a loop inside a loop, here drawing rows of a staircase.
            for (int row = 1; row <= 4; row++)
            {
                string line = "";
                for (int step = 0; step < row; step++)
                {
                    line += "##";
                }
                Console.WriteLine(line);
            }
        }
    }
}
OutputText
Burning... fuel left: 3
Burning... fuel left: 2
Burning... fuel left: 1
Enemies over 5 waves: 150
Attempts: 5
Vowels in lighthouse: 4
Picked: 1 2 4 5 7
##
####
######
########

break and continue#

continue skips the rest of the current pass and goes on to the next one; break leaves the loop completely. In the sample, multiples of 3 are skipped, and the loop stops at 8. Inside nested loops, both only affect the innermost loop. To stop searching through a grid as soon as you find something, the cleanest way is usually to put the loops in a method and return.

Off-by-one errors#

The most common loop bug is running one time too many or too few. Counting from 0 to count - 1 is written for (int i = 0; i < count; i++): start at 0, use <, not <=. If you need 1 to 10, write for (int i = 1; i <= 10; i++). When unsure, test with a count of 1 and 0 in your head: a loop that runs once for count = 1 and zero times for count = 0 is right.

Arrays and lists#

When you have many values of the same kind (lap times, enemies, inventory items), you store them in a collection instead of in enemy1, enemy2, enemy3.

Arrays/Program.cs.NET consoleC#
using System;
using System.Collections.Generic;

namespace Arrays
{
    class Program
    {
        static void Main()
        {
            // An array holds a fixed number of values of one type. Indexes start at 0.
            int[] lapTimes = { 62, 58, 71, 55, 60 };
            Console.WriteLine($"{lapTimes.Length} laps; first {lapTimes[0]} s, last {lapTimes[lapTimes.Length - 1]} s");

            int total = 0;
            int best = lapTimes[0];
            foreach (int time in lapTimes)
            {
                total += time;
                if (time < best)
                {
                    best = time;
                }
            }
            Console.WriteLine($"Total {total} s, average {total / (float)lapTimes.Length:F1} s, best {best} s");

            // Arrays are reference types: two variables can share one array.
            int[] sameArray = lapTimes;
            sameArray[0] = 99;
            Console.WriteLine($"lapTimes[0] is now {lapTimes[0]}");

            int[] realCopy = (int[])lapTimes.Clone();
            realCopy[0] = 1;
            Console.WriteLine($"After changing the copy: {lapTimes[0]} and {realCopy[0]}");

            Array.Sort(lapTimes);
            Console.WriteLine("Sorted: " + string.Join(", ", lapTimes));

            // A 2D array is a grid: [row, column].
            char[,] map = new char[4, 9];
            for (int row = 0; row < map.GetLength(0); row++)
            {
                for (int col = 0; col < map.GetLength(1); col++)
                {
                    bool isEdge = row == 0 || row == map.GetLength(0) - 1 || col == 0 || col == map.GetLength(1) - 1;
                    map[row, col] = isEdge ? '#' : '.';
                }
            }
            map[1, 2] = '@';
            map[2, 6] = '$';
            for (int row = 0; row < map.GetLength(0); row++)
            {
                string line = "";
                for (int col = 0; col < map.GetLength(1); col++)
                {
                    line += map[row, col];
                }
                Console.WriteLine(line);
            }

            // A List grows and shrinks. Use it when the number of items changes.
            List<string> backpack = new List<string> { "rope", "lamp" };
            backpack.Add("map");
            backpack.Remove("rope");
            backpack.Insert(0, "compass");
            Console.WriteLine($"{backpack.Count} items: {string.Join(", ", backpack)}");
            Console.WriteLine($"Has a lamp: {backpack.Contains("lamp")}");
        }
    }
}
OutputText
5 laps; first 62 s, last 60 s
Total 306 s, average 61.2 s, best 55 s
lapTimes[0] is now 99
After changing the copy: 99 and 1
Sorted: 55, 58, 60, 71, 99
#########
#.@.....#
#.....$.#
#########
3 items: compass, lamp, map
Has a lamp: True

Arrays#

  • Create with values, int[] times = { 62, 58, 71 };, or empty with a size, new int[10]. An empty array is filled with the type's default: 0, false, or null.
  • Index from 0: the first item is times[0] and the last is times[times.Length - 1]. Reading times[times.Length] throws an IndexOutOfRangeException.
  • Size is fixed. An array can't grow. To add items, you need a bigger array or a List.
  • Arrays are reference types. int[] sameArray = lapTimes; doesn't copy the numbers; both names now refer to one array, so changing one changes "both". Use Clone() for a real copy. The section on values and references explains why.
  • Helpers live on the Array class: Array.Sort, Array.Reverse, Array.IndexOf.

A 2D array, new char[rows, columns], is a grid: ideal for board games, tile maps and inventories with slots. map.GetLength(0) is the number of rows and GetLength(1) the number of columns. Nested loops visit every cell, rows outside and columns inside. T23 · Grids, A* Pathfinding & Turn-Based Systems builds grid games on this.

Lists#

List<string> is a list of strings that can grow and shrink. The type in angle brackets says what it holds; that's a generic type, explained in T04 · C# Beyond Basics: OOP, Generics, Collections, LINQ, Delegates & Memory. You need using System.Collections.Generic; at the top.

  • Add(item), Insert(index, item), Remove(item), RemoveAt(index), Clear().
  • Count is the number of items (arrays call it Length: a small annoyance you'll stop noticing).
  • Contains(item), IndexOf(item), and indexing with list[i] work like arrays.

Use an array when the number of items is fixed (the 4 wheels of a car, the 12 months, a grid), and a List when it changes (enemies alive, items picked up). Unity's Inspector can show and edit both, which you'll use constantly from T05 · Scripting in Unity: Lifecycle, Serialization, Coroutines & Awaitable on.

Methods#

A method is a named block of code you can run (call) from elsewhere. Methods let you name a step (HealthBar is easier to read than six lines of string building), reuse it, test it on its own, and change it in one place.

The parts of a methodC#
//  ┌ static: belongs to the class, not to an object (more in Classes)
//  │      ┌ return type: what it gives back (void = nothing)
//  │      │   ┌ name: a verb, PascalCase
//  │      │   │       ┌ parameters: inputs, each with a type
static int Square(int x)
{
    return x * x;      // return hands the result back to the caller
}

int area = Square(7);  // calling it: 7 is the argument, area becomes 49
Methods/Program.cs.NET consoleC#
using System;

namespace Methods
{
    class Program
    {
        static void Main()
        {
            Console.WriteLine(Square(7));
            Console.WriteLine(Clamp(140, 0, 100));
            Console.WriteLine(Clamp(0.5f, 0f, 0.25f));

            Greet("Robin");
            Greet("Ada", "Welcome back");
            Greet(greeting: "Ahoy", name: "Captain");

            Console.WriteLine(Sum(1, 2, 3, 4));

            int coins = 10;
            AddBonus(coins);
            Console.WriteLine($"After AddBonus: {coins}");
            AddBonus(ref coins);
            Console.WriteLine($"After AddBonus(ref): {coins}");

            if (TryReadLevel("12", out int level))
            {
                Console.WriteLine($"Level {level}");
            }
            if (!TryReadLevel("twelve", out _))
            {
                Console.WriteLine("'twelve' isn't a level number");
            }

            Console.WriteLine(Factorial(5));
            Console.WriteLine(HealthBar(7, 10));
        }

        // Takes one int and gives one back.
        static int Square(int x)
        {
            return x * x;
        }

        // Two methods may share a name if their parameters differ: this is overloading.
        static int Clamp(int value, int min, int max)
        {
            if (value < min) return min;
            if (value > max) return max;
            return value;
        }

        static float Clamp(float value, float min, float max) => value < min ? min : value > max ? max : value;

        // greeting is optional: callers can leave it out and get "Hello".
        static void Greet(string name, string greeting = "Hello")
        {
            Console.WriteLine($"{greeting}, {name}!");
        }

        // params lets callers pass any number of values.
        static int Sum(params int[] numbers)
        {
            int total = 0;
            foreach (int n in numbers)
            {
                total += n;
            }
            return total;
        }

        // amount is a copy, so this changes nothing outside the method.
        static void AddBonus(int amount)
        {
            amount += 5;
        }

        // ref passes the caller's variable itself.
        static void AddBonus(ref int amount)
        {
            amount += 5;
        }

        // The Try pattern: return whether it worked, and the result through an out parameter.
        static bool TryReadLevel(string text, out int level)
        {
            return int.TryParse(text, out level) && level >= 1;
        }

        // A method that calls itself: recursion.
        static int Factorial(int n) => n <= 1 ? 1 : n * Factorial(n - 1);

        static string HealthBar(int current, int max, int width = 10)
        {
            int filled = current * width / max;
            return "[" + new string('#', filled) + new string('.', width - filled) + "]";
        }
    }
}
OutputText
49
100
0.25
Hello, Robin!
Welcome back, Ada!
Ahoy, Captain!
10
After AddBonus: 10
After AddBonus(ref): 15
Level 12
'twelve' isn't a level number
120
[#######...]

Parameters and return values#

  • Parameters are the method's inputs, declared with types. The values you pass when calling are arguments.
  • return ends the method and hands back a value of the return type. A method with a return type must return on every path, or you get error CS0161 ("not all code paths return a value").
  • void means the method returns nothing; it does something instead (prints, changes an object).
  • Expression-bodied methods, written with =>, are a shorthand for methods whose whole body is one expression: static int Square(int x) => x * x;.

Overloads, optional and named arguments, params#

  • Overloading: several methods can share a name if their parameter lists differ. Clamp(int, int, int) and Clamp(float, float, float) are two methods; the compiler picks the one that matches your arguments. Unity's API uses this everywhere.
  • Optional parameters have a default value: string greeting = "Hello". Callers can leave them out. They must come after the required ones.
  • Named arguments (greeting: "Ahoy") let you pass arguments in any order, and make calls with several numbers readable: in the arena, maxHealth: 24 and power: 6 say what each number is.
  • params lets the caller pass any number of arguments, which arrive as an array.

Copies, ref and out#

By default, a parameter receives a copy of the argument's value. AddBonus(coins) adds 5 to its own copy, and coins outside is still 10. That's usually what you want: a method can't mess with your variables behind your back.

When a method should change the caller's variable, mark the parameter ref, at both the declaration and the call. out is similar, for values the method produces: the method must assign it, and the caller doesn't need to give it a value first. Its most common use is the Try pattern: int.TryParse(text, out int value) returns true or false for success, and hands back the number through out. Write out _ when you only care whether it worked. (There's also in, a read-only ref for passing big structs without copying them; you won't need it for a while.)

Prefer return values. Reach for ref and out only when a method genuinely needs to give back more than one thing, and even then, a small class or struct that holds both results is often clearer.

Recursion#

A method that calls itself is recursive. Factorial(5) is 5 * Factorial(4), which is 4 * Factorial(3), and so on down to the base case, n <= 1, which stops it. Recursion is natural for things that contain smaller things of the same shape: folders in folders, the GameObjects under a Transform, flood-filling a grid. Without a base case it never stops, and the program crashes with a StackOverflowException, which in Unity can take the Editor down with it.

Writing good methods#

  • One job each, described by a verb phrase: CalculateDamage, SpawnWave, IsInRange. If the honest name has "and" in it, it's two methods.
  • Short: if you scroll to see all of it, look for a piece to pull out into its own method.
  • Few parameters: more than three or four suggests the method is doing too much, or that the values belong together in a class.
  • No boolean "mode" switches: Move(true) tells a reader nothing. Two methods, Walk() and Run(), do.
  • Return results rather than printing them, where you can. HealthBar returns a string and lets the caller decide where it goes, so the same method works for the console, Unity's log and a UI label.

T11 · Clean Code, SOLID & a C# Style Guide for Unity turns these into a full style guide.

Classes and objects#

So far every method has been static, working only on the values passed in. Games are made of things with their own data: this lantern has 60 units of fuel, that one has 30. A class describes a kind of thing, its data and what it can do, and each object (or instance) made from it has its own copy of the data.

class Lantern the blueprint string owner float fuel LightMode Mode SetMode() Burn() Refill() static MadeSoFar = 2 new Lantern(…) owner = "Mira" fuel = 24 Mode = Bright owner = "Tom" fuel = 15 Mode = Dim mine yours variables
One class, two objects. Each object has its own fields; the static field belongs to the class and is shared. The variables hold references that point at the objects.

This lantern class is in its own file, the way real projects organise classes. Read it top to bottom, then the program that uses it:

Classes/Lantern.cs.NET consoleC#
using System;

namespace Classes
{
    // The three settings a lantern can be in. An enum gives a fixed set of named values.
    public enum LightMode
    {
        Off,
        Dim,
        Bright,
    }

    // A class is a blueprint. Each Lantern object made from it has its own name, fuel and mode.
    public class Lantern
    {
        public const float Capacity = 100f;

        // Static: one value shared by every lantern, not one per lantern.
        public static int MadeSoFar;

        // Fields hold an object's data. Private: only code inside Lantern can touch them.
        readonly string owner;
        float fuel;

        // Properties: data with rules. Outside code can read Mode but only Lantern can set it.
        public LightMode Mode { get; private set; }
        public string Owner => owner;
        public float Fuel
        {
            get => fuel;
            private set => fuel = Math.Clamp(value, 0f, Capacity);
        }
        public bool IsEmpty => fuel <= 0f;

        // The constructor runs once, when the object is created with new.
        public Lantern(string owner, float startingFuel)
        {
            this.owner = owner;
            Fuel = startingFuel;
            Mode = LightMode.Off;
            MadeSoFar++;
        }

        public void SetMode(LightMode newMode)
        {
            if (IsEmpty && newMode != LightMode.Off)
            {
                Console.WriteLine($"{owner}'s lantern has no fuel.");
                return;
            }
            Mode = newMode;
        }

        // Burns fuel for some hours, at a rate that depends on the mode.
        public void Burn(float hours)
        {
            float perHour = Mode switch
            {
                LightMode.Dim => 5f,
                LightMode.Bright => 12f,
                _ => 0f,
            };
            Fuel -= perHour * hours;
            if (IsEmpty)
            {
                Mode = LightMode.Off;
            }
        }

        public void Refill() => Fuel = Capacity;

        public override string ToString() => $"{owner}'s lantern: {Mode}, {fuel:F0}/{Capacity:F0} fuel";
    }
}
Classes/Program.cs.NET consoleC#
using System;

namespace Classes
{
    class Program
    {
        static void Main()
        {
            var mine = new Lantern("Mira", 60f);
            var yours = new Lantern("Tom", 30f);

            mine.SetMode(LightMode.Bright);
            yours.SetMode(LightMode.Dim);
            mine.Burn(3f);
            yours.Burn(3f);
            Console.WriteLine(mine);
            Console.WriteLine(yours);

            mine.Burn(4f);
            Console.WriteLine(mine);
            mine.SetMode(LightMode.Dim);

            mine.Refill();
            mine.SetMode(LightMode.Dim);
            Console.WriteLine(mine);

            Console.WriteLine($"Lanterns made: {Lantern.MadeSoFar}");
            Console.WriteLine($"Mode as a number: {(int)LightMode.Bright}");
        }
    }
}
OutputText
Mira's lantern: Bright, 24/100 fuel
Tom's lantern: Dim, 15/100 fuel
Mira's lantern: Off, 0/100 fuel
Mira's lantern has no fuel.
Mira's lantern: Dim, 100/100 fuel
Lanterns made: 2
Mode as a number: 2

Fields, constructors and new#

  • Fields are variables that belong to an object: owner and fuel. Each lantern has its own. A field that's never assigned holds its type's default (0, false, null).
  • A constructor is a special method with the class's name and no return type. It runs once, when you write new Lantern("Mira", 60f), and its job is to put the new object into a valid starting state.
  • this means "the object this code is running on". this.owner = owner; tells the field apart from the parameter with the same name.
  • new creates an object and gives you a reference to it. var mine = new Lantern(…) is the usual way to write it, because the type is right there on the line.
  • Object initialisers set public fields or properties straight after construction: new Marker { Label = "flag", X = 1 }, used in the next section.

Access modifiers and encapsulation#

Every member has an access level that says who may use it:

ModifierWho can use it
publicAny code
privateOnly code inside the same class. The default for members, if you write nothing
protectedThe class and classes derived from it (T04 · C# Beyond Basics: OOP, Generics, Collections, LINQ, Delegates & Memory)
internalAny code in the same assembly. The default for classes

The lantern's fuel is private, so the only way to change it is through Burn and Refill, and those keep it between 0 and 100. No other code can set it to −40 by mistake. That's encapsulation: an object guards its own data and exposes only safe operations. It's what lets you trust a class without reading all the code that uses it.

Properties#

A property looks like a field from outside (lantern.Fuel) but runs code when it's read (get) or written (set). The lantern shows the common shapes:

  • Mode { get; private set; }: an auto-property. Anyone can read it; only the class can change it. The most common property in real code.
  • public string Owner => owner;: a read-only property that just returns a field.
  • public bool IsEmpty => fuel <= 0f;: a computed property, worked out each time it's read, so it can never be out of date.
  • Fuel, with a set that clamps: a property that validates, so every assignment, even inside the class, stays in range.

Use properties for anything other classes read. Fields stay private.

Static members#

A static member belongs to the class itself, not to any object: there's one Lantern.MadeSoFar, shared by all lanterns, and you use it through the class name. Static methods, like the ones in the earlier sections, don't need an object at all: Math.Abs, Mathf.Clamp and Debug.Log are static.

Use static for things that are genuinely one-per-program: pure helper methods that only work on their parameters, and constants. Be wary of static fields that change: they're shared by everything, any code can change them, and in Unity they can keep their values between Play mode sessions (T05 · Scripting in Unity: Lifecycle, Serialization, Coroutines & Awaitable). T12 · Game Programming Patterns in Unity discusses the singleton pattern, the most popular and most argued-about use of static.

const and readonly#

const values are fixed when you compile (public const float Capacity = 100f;) and are implicitly static. readonly fields are set once, in their declaration or the constructor, and can't change after that; they can hold values only known at runtime, such as the lantern's owner. Use readonly for fields that shouldn't change after construction: it documents the intent and the compiler enforces it.

Enums#

An enum is a type with a fixed set of named values: LightMode.Off, LightMode.Dim, LightMode.Bright. Use one whenever a value is "one of these few choices": a weapon type, a game state, a direction, a difficulty. Enums beat strings ("bright" can be mistyped; LightMode.Brigt is a compile error) and beat magic numbers (mode = 2 means nothing to a reader).

Underneath, each value is an int, starting from 0, so (int)LightMode.Bright is 2. Enums go naturally with switch, as in Burn. Unity shows an enum field in the Inspector as a dropdown.

ToString and namespaces#

Every object has a ToString() method that turns it into text, which is what Console.WriteLine(mine) and string interpolation call. By default it prints the class name. The lantern overrides it (public override string ToString()) to print something useful, which makes logging much easier. override is part of inheritance, covered in T04 · C# Beyond Basics: OOP, Generics, Collections, LINQ, Delegates & Memory.

Both lantern files start with namespace Classes, so Program can use Lantern without a using line. In Unity, putting your scripts in a namespace (namespace IslandGame) keeps your Player class from clashing with a Player class in some Asset Store package.

Values and references#

This is the idea that explains the shared-array surprise above, and a whole family of bugs. Every type in C# is one of two kinds:

  • Value types: the variable holds the value itself. Assigning copies it. All the number types, bool, char, enums, and structs, including Unity's Vector3, Quaternion and Color.
  • Reference types: the variable holds a reference, an arrow to an object that lives elsewhere. Assigning copies the arrow, so both variables point at the same object. All classes, including string, arrays, List, and every Unity component and GameObject.
struct (value type) start X = 1, Y = 2 copy X = 50, Y = 2 Point copy = start; copies the whole value: two independent points class (reference type) flag sameFlag Marker object X = 50 Marker sameFlag = flag; copies the arrow: one object, two ways to reach it
Assignment always copies what's in the variable. For a struct that's the whole value; for a class it's only the reference.
References/Program.cs.NET consoleC#
using System;

namespace References
{
    // A class: variables hold a reference to one shared object.
    class Marker
    {
        public string Label;
        public int X;
    }

    // A struct: variables hold the value itself, and assignment copies it.
    struct Point
    {
        public int X;
        public int Y;
    }

    class Program
    {
        static void Main()
        {
            var flag = new Marker { Label = "flag", X = 1 };
            Marker sameFlag = flag;       // copies the reference, not the object
            sameFlag.X = 50;
            Console.WriteLine($"flag.X = {flag.X}");

            var start = new Point { X = 1, Y = 2 };
            Point copy = start;           // copies the whole value
            copy.X = 50;
            Console.WriteLine($"start.X = {start.X}, copy.X = {copy.X}");

            MoveRight(flag);
            MoveRight(start);
            Console.WriteLine($"After MoveRight: flag.X = {flag.X}, start.X = {start.X}");

            // null means "no object". Using it as if it were one throws an exception.
            Marker missing = null;
            Console.WriteLine(missing == null);
            Console.WriteLine(missing?.Label ?? "(no label)");
            try
            {
                Console.WriteLine(missing.Label);
            }
            catch (NullReferenceException)
            {
                Console.WriteLine("NullReferenceException: missing is null");
            }

            // Strings are references too, but they can't be changed, so they behave like values.
            string word = "lamp";
            string other = word;
            other += "light";
            Console.WriteLine($"{word} / {other}");
        }

        static void MoveRight(Marker marker) => marker.X += 10;

        static void MoveRight(Point point) => point.X += 10;
    }
}
OutputText
flag.X = 50
start.X = 1, copy.X = 50
After MoveRight: flag.X = 60, start.X = 1
True
(no label)
NullReferenceException: missing is null
lamp / lamplight

The same rule applies to method parameters. MoveRight(flag) receives a copy of the reference, which points at the same object, so the change sticks. MoveRight(start) receives a copy of the whole point, changes the copy, and the original is untouched.

null and NullReferenceException#

A reference-type variable can hold null: an arrow to nothing. Using it as if it pointed at an object (reading a field, calling a method) throws a NullReferenceException, the error you'll see more than any other in Unity, usually because a field in the Inspector was never assigned. The cure is to make sure the reference is set, and the defence is to check: if (missing != null). Value types can't be null.

Why this matters in Unity: Vector3 is a struct#

transform.position is a property that returns a Vector3, and Vector3 is a struct. So the property hands you a copy. That's why this line is a compile error:

Error CS1612C#
transform.position.y = 5f;   // CS1612: Cannot modify the return value of 'Transform.position' because it is not a variable

Changing y on a temporary copy would do nothing, so the compiler refuses. The fix is three steps: copy, change, assign back. You'll write it constantly, and the Unity sample below does exactly this.

The copy, change, assign patternC#
Vector3 position = transform.position;   // 1. copy the struct
position.y = 5f;                         // 2. change the copy
transform.position = position;           // 3. assign it back

Structs suit small values that behave like numbers: a position, a colour, a pair of grid coordinates. Classes suit things with an identity that several parts of the game refer to: an enemy, an inventory, a lantern. T04 · C# Beyond Basics: OOP, Generics, Collections, LINQ, Delegates & Memory goes deeper, including where each kind lives in memory and what that means for performance.

Errors and exceptions#

Things go wrong in three different ways, and each is handled differently:

  • Compile errors: the compiler can't understand the code or sees a type mismatch. Nothing runs. In Unity they show in red in the Console, with a file and line.
  • Exceptions (runtime errors): the code compiled, but something impossible happened while running, such as using null or reading past the end of an array.
  • Logic bugs: the code runs without complaint and does the wrong thing. No message at all; you find them by testing and by logging.

Reading compile errors#

A compile error has a code, a message, a file and a line, like Lantern.cs(42,13): error CS0103 followed by "The name 'fule' does not exist in the current context": file Lantern.cs, line 42, column 13. Fix the first error first: one missing brace can cause twenty errors after it, which all vanish when you add the brace. The ones you'll meet most:

CodeMessage (shortened)Usual cause
CS1002; expectedA missing semicolon, usually at the end of the line before the one reported
CS1513} expectedA missing closing brace somewhere above
CS0103The name 'x' does not exist in the current contextA typo, wrong capitalisation, or a variable used outside its scope
CS0246The type or namespace name 'X' could not be foundA missing using line, or a misspelled type
CS0029Cannot implicitly convert type 'A' to 'B'Putting a value in a variable of the wrong type: int n = "3";
CS0664Literal of type double cannot be implicitly converted to type 'float'A missing f suffix: float speed = 4.5;
CS0266Cannot implicitly convert type 'double' to 'float'. An explicit conversion existsPutting a double variable or result into a float without a cast
CS0165Use of unassigned local variable 'x'Reading a local variable before every path has given it a value
CS0161not all code paths return a valueA method with a return type that can reach its end without return
CS1612Cannot modify the return value of '…' because it is not a variableChanging a field of a struct returned by a property, such as transform.position.x

Exceptions#

Errors/Program.cs.NET consoleC#
using System;

namespace Errors
{
    class Program
    {
        static void Main()
        {
            // Parse throws if the text isn't a number. TryParse returns false instead.
            string typed = "abc";
            try
            {
                int value = int.Parse(typed);
                Console.WriteLine($"Got {value}");
            }
            catch (FormatException)
            {
                Console.WriteLine($"'{typed}' isn't a whole number");
            }

            if (!int.TryParse(typed, out int parsed))
            {
                Console.WriteLine($"TryParse said no, and set parsed to {parsed}");
            }

            // An exception jumps out of every method until something catches it.
            int[] slots = new int[3];
            try
            {
                Fill(slots, 5);
            }
            catch (IndexOutOfRangeException)
            {
                Console.WriteLine("IndexOutOfRangeException: slot 3 doesn't exist (0 to 2 do)");
            }
            finally
            {
                Console.WriteLine("finally always runs");
            }

            // Throw your own exception when a method is used wrongly.
            try
            {
                Spend(10, 25);
            }
            catch (InvalidOperationException e)
            {
                Console.WriteLine($"{e.GetType().Name}: {e.Message}");
            }
        }

        static void Fill(int[] slots, int count)
        {
            for (int i = 0; i < count; i++)
            {
                slots[i] = i * 10;
                Console.WriteLine($"Filled slot {i}");
            }
        }

        static int Spend(int coins, int price)
        {
            if (price > coins)
            {
                throw new InvalidOperationException($"Can't spend {price} coins with only {coins}.");
            }
            return coins - price;
        }
    }
}
OutputText
'abc' isn't a whole number
TryParse said no, and set parsed to 0
Filled slot 0
Filled slot 1
Filled slot 2
IndexOutOfRangeException: slot 3 doesn't exist (0 to 2 do)
finally always runs
InvalidOperationException: Can't spend 25 coins with only 10.

When something impossible happens, .NET throws an exception: an object describing what went wrong. It travels up through the methods that were called, skipping the rest of each one, until a try block with a matching catch handles it. If nothing catches it, the program stops (a console program) or the error is logged (Unity, see below). A finally block runs either way, for clean-up.

You can throw your own with throw new InvalidOperationException("…") when a method is used in a way that can't work. Choose the type that fits: ArgumentException or ArgumentOutOfRangeException for bad parameters, InvalidOperationException for "not allowed right now".

When to use them:

  • Catch exceptions at the edges of your program, where the outside world can fail: reading files, parsing what a player typed, network calls. There, failure is normal and you can do something sensible (show a message, use a default).
  • Prefer the Try pattern for expected failures. int.TryParse is clearer and faster than catching a FormatException. The Purse exercise below does the same with TrySpend.
  • Don't wrap gameplay code in try/catch to make errors go away. An exception in your own logic is a bug report. Catching and ignoring it hides the bug and leaves the game in a half-updated state.
  • Never write an empty catch { }. If you must catch something, at least log it.

Finding logic bugs#

  1. Reproduce it: find the steps that make it happen every time.
  2. Print what's happening: Console.WriteLine (or Debug.Log in Unity) the values you think you have. Most bugs are a value that isn't what you assumed.
  3. Use the debugger: set a breakpoint, step line by line and watch the variables, as you set up in T01. It's slower to start and much faster to finish than a trail of log lines.
  4. Explain the code out loud, line by line, to a person or a rubber duck. Saying what each line does is often enough to hear the line that doesn't do it.

T18 · Debugging, Automated Tests & a Git Workflow for Unity is a whole chapter on debugging and automated tests.

From the console to Unity#

Everything above works the same in a Unity script. A few things are different around the edges:

  • No Main. A script is a class that derives from MonoBehaviour, attached to a GameObject. Unity creates it and calls its methods (Awake, Start, Update…) at the right moments. T05 · Scripting in Unity: Lifecycle, Serialization, Coroutines & Awaitable covers when each one runs.
  • No constructors for components. Unity creates them, so you set them up in Awake or Start instead.
  • Debug.Log instead of Console.WriteLine. Console.WriteLine doesn't appear in Unity's Console window. Debug.LogWarning and Debug.LogError print in yellow and red.
  • Fields can appear in the Inspector. Public fields, and private fields marked [SerializeField], are shown and saved by Unity, so designers can change them without touching code. Prefer [SerializeField] private: you get the Inspector field without letting every other script change it.
  • The file name must match the class name for a MonoBehaviour: class TreasureCounter lives in TreasureCounter.cs.

Two Unity scripts#

This component uses most of this chapter: fields, an array, a loop, maths with a cast, a switch expression and an interpolated string. Add it to any GameObject, press Play, and read the Console. Then change the values in the Inspector and play again.

TreasureCounter.csUnity sandboxC#
using UnityEngine;

namespace Handbook.T03
{
    // The chapter's C# inside a Unity component: Inspector fields, an array, a loop, a switch.
    public class TreasureCounter : MonoBehaviour
    {
        [SerializeField] string captainName = "Mira";
        [SerializeField] int[] chestValues = { 120, 45, 300, 80 };
        [SerializeField, Range(0f, 1f)] float crewShare = 0.25f;

        void Start()
        {
            int total = 0;
            foreach (int value in chestValues)
            {
                total += value;
            }

            int kept = Mathf.RoundToInt(total * (1f - crewShare));
            Debug.Log($"{captainName} opened {chestValues.Length} chests worth {total} coins and keeps {kept}. Rank: {RankFor(kept)}");
        }

        static string RankFor(int coins) => coins switch
        {
            < 100 => "deckhand",
            < 400 => "first mate",
            _ => "legend",
        };
    }
}

With the default values, the Console shows this (545 × 0.75 is 408.75, which rounds to 409):

ConsoleText
Mira opened 4 chests worth 545 coins and keeps 409. Rank: legend

The second script is the struct pattern from above, used for real: it bobs an object up and down.

Floater.csUnity sandboxC#
using UnityEngine;

namespace Handbook.T03
{
    // Floats the GameObject up and down. Shows the copy, change, assign pattern for structs.
    public class Floater : MonoBehaviour
    {
        [SerializeField, Min(0f)] float height = 0.5f;
        [SerializeField, Min(0f)] float speed = 2f;

        float startY;

        void Start() => startY = transform.position.y;

        void Update()
        {
            // transform.position.y = ...  would be error CS1612: position gives you a copy.
            Vector3 position = transform.position;
            position.y = startY + Mathf.Sin(Time.time * speed) * height;
            transform.position = position;
        }
    }
}

Which C# Unity 6 understands#

Unity 6.3 compiles C# 9 against the .NET Standard 2.1 library. Books, courses and Microsoft's documentation often use newer C#, so some of what you read won't compile in Unity:

FeatureC# versionIn Unity 6.3?
Everything in this chapter (switch expressions, relational and logical patterns, is not null, target-typed new())up to 9Yes
Records, init-only setters9Only with a workaround, and records can't be serialized by Unity: avoid
File-scoped namespaces (namespace Game;), global using10No
record struct, extended property patterns10No
Raw string literals ("""…"""), list patterns, required members11No
Primary constructors for classes, collection expressions ([1, 2, 3])12No

Naming and layout conventions#

ThingStyleExample
Classes, structs, enums, enum valuesPascalCase, nounsLantern, LightMode.Bright
MethodsPascalCase, verbsBurn, TrySpend
Properties, constantsPascalCaseIsEmpty, MaxSpring
Local variables, parameterscamelCasestartingFuel
Private fieldscamelCase (this handbook); many teams add _: _fuelfuel
BooleansRead as a yes/no questionisOpen, HasKey, CanJump

Braces go on their own lines and code is indented four spaces, as in every example here. Your IDE can format a file for you. Whatever style a project uses, follow it consistently; T11 · Clean Code, SOLID & a C# Style Guide for Unity covers a full style guide.

Guided build: Clockwork Arena#

You'll build a complete turn-based duel between two wind-up automatons, as a console program in four files. Each automaton has health, power, armour and a spring it winds up and spends. Each turn it chooses one of three moves:

  • Strike (costs 2 spring): damage is power plus a dice roll minus the target's armour, at least 1.
  • Brace (free): winds the spring up by 4 and halves the next hit it takes.
  • Overwind (costs 6 spring): twice its power plus a roll, and armour doesn't help.

It uses every part of this chapter: constants, integer maths, an enum, properties with rules, methods, a class with private state, a static class, an array that wraps around, for loops, if chains, a switch, exceptions and string formatting. Create a new console project called Arena (with LangVersion 9.0) and add the files one at a time.

Step 1 · A dice you can predict#

Games use random numbers, but random output is hard to test: you can't check a result you can't predict. So this dice replays a fixed list of rolls. Every run prints the same duel, and you can compare your output with the one at the end of this section. (Exercise 8 swaps in real randomness.)

Arena/Dice.cs.NET consoleC#
using System;

namespace Arena
{
    // A dice that replays a fixed list of rolls, so every run of the program prints the same duel.
    public class Dice
    {
        readonly int[] rolls;
        int next;

        public Dice(int[] rolls)
        {
            if (rolls == null || rolls.Length == 0)
            {
                throw new ArgumentException("A dice needs at least one roll.", nameof(rolls));
            }
            this.rolls = rolls;
        }

        public int Roll()
        {
            int value = rolls[next];
            next = (next + 1) % rolls.Length;   // wrap back to the start after the last roll
            return value;
        }
    }
}

readonly means rolls can only be set in the constructor. The constructor refuses an empty list by throwing, because a dice with no faces can never work: better to fail loudly at the line that made the mistake than mysteriously later. (next + 1) % rolls.Length goes 0, 1, … 11 and back to 0.

Step 2 · The automaton#

Arena/Automaton.cs.NET consoleC#
using System;

namespace Arena
{
    public enum Move
    {
        Strike,
        Brace,
        Overwind,
    }

    // One clockwork fighter: its numbers, and the rules for changing them.
    public class Automaton
    {
        public const int StrikeCost = 2;
        public const int OverwindCost = 6;
        public const int MaxSpring = 10;

        int health;
        int spring;

        public string Name { get; }
        public int MaxHealth { get; }
        public int Power { get; }
        public int Armour { get; }
        public bool IsBracing { get; private set; }

        public int Health
        {
            get => health;
            private set => health = Math.Clamp(value, 0, MaxHealth);
        }

        public int Spring
        {
            get => spring;
            private set => spring = Math.Clamp(value, 0, MaxSpring);
        }

        public bool IsDown => health == 0;

        public Automaton(string name, int maxHealth, int power, int armour)
        {
            Name = name;
            MaxHealth = maxHealth;
            Power = power;
            Armour = armour;
            Health = maxHealth;
            Spring = 4;
        }

        // A simple plan: the first rule that matches decides the move.
        public Move ChooseMove(Automaton opponent)
        {
            if (spring < StrikeCost)
            {
                return Move.Brace;
            }
            if (spring >= OverwindCost && opponent.Health > Power)
            {
                return Move.Overwind;
            }
            if (health * 3 < MaxHealth && spring < OverwindCost)
            {
                return Move.Brace;
            }
            return Move.Strike;
        }

        public void Spend(int amount)
        {
            if (amount > spring)
            {
                throw new InvalidOperationException($"{Name} needs {amount} spring but has {spring}.");
            }
            Spring -= amount;
        }

        // Bracing halves the next hit and winds the spring back up.
        public void Brace()
        {
            IsBracing = true;
            Spring += 4;
        }

        // Returns the damage actually taken, so the caller can report it.
        public int TakeHit(int damage)
        {
            if (IsBracing)
            {
                damage /= 2;
                IsBracing = false;
            }
            Health -= damage;
            return damage;
        }

        public string HealthBar(int width = 10)
        {
            int filled = (health * width + MaxHealth - 1) / MaxHealth;   // round up, so 1 health still shows
            return "[" + new string('#', filled) + new string('.', width - filled) + "]";
        }
    }
}

Things to notice:

  • Constants hold the rules' numbers (StrikeCost, OverwindCost), so balancing the game means changing one line, and the code says Automaton.StrikeCost rather than a mysterious 2.
  • Get-only properties (Name, MaxHealth, Power, Armour) are set once in the constructor and never change.
  • Validating properties keep Health and Spring inside their ranges whatever any method does to them.
  • ChooseMove is the automaton's "brain": a list of rules checked in order, where the first match decides. It's a tiny AI, and T15 · State Machines, Hierarchical FSMs & Behaviour Trees and T22 · Game AI: NavMesh, Steering, FSM / BT / GOAP / Utility, Perception grow this idea into state machines and behaviour trees.
  • Spend throws if the spring is too low. ChooseMove should never let that happen, so if it ever does, it's a bug, and an exception makes it impossible to miss.
  • HealthBar rounds up with integer maths: adding MaxHealth - 1 before dividing means any health above 0 shows at least one #.

Step 3 · The rules of a duel#

Arena/Duel.cs.NET consoleC#
using System;

namespace Arena
{
    // The rules of a fight between two automatons. Static: there's no "duel object", just rules.
    public static class Duel
    {
        public static Automaton Run(Automaton first, Automaton second, Dice dice, int maxRounds)
        {
            Console.WriteLine($"=== Clockwork Arena: {first.Name} vs {second.Name} ===");

            for (int round = 1; round <= maxRounds; round++)
            {
                Console.WriteLine($"Round {round}");
                if (TakeTurn(first, second, dice) || TakeTurn(second, first, dice))
                {
                    Automaton winner = first.IsDown ? second : first;
                    Console.WriteLine($"{winner.Name} wins in round {round} with {winner.Health}/{winner.MaxHealth} health left.");
                    return winner;
                }
            }

            Console.WriteLine($"Time! Both still standing after {maxRounds} rounds.");
            return null;
        }

        // One automaton acts. Returns true if the other one went down.
        static bool TakeTurn(Automaton actor, Automaton target, Dice dice)
        {
            Move move = actor.ChooseMove(target);
            string report;

            switch (move)
            {
                case Move.Strike:
                {
                    actor.Spend(Automaton.StrikeCost);
                    int roll = dice.Roll();
                    int damage = Math.Max(1, actor.Power + roll - target.Armour);
                    int taken = target.TakeHit(damage);
                    report = $"{actor.Name} strikes (rolled {roll}): {taken} damage";
                    break;
                }
                case Move.Overwind:
                {
                    actor.Spend(Automaton.OverwindCost);
                    int roll = dice.Roll();
                    int taken = target.TakeHit(actor.Power * 2 + roll);   // too strong for armour
                    report = $"{actor.Name} OVERWINDS (rolled {roll}): {taken} damage";
                    break;
                }
                case Move.Brace:
                    actor.Brace();
                    report = $"{actor.Name} braces and winds up";
                    break;
                default:
                    throw new ArgumentOutOfRangeException(nameof(move), move, "Unknown move");
            }

            Console.WriteLine($"  {report,-38} {target.Name,-6} {target.HealthBar()} {target.Health,2}/{target.MaxHealth}");
            return target.IsDown;
        }
    }
}
  • Duel is a static class: it holds no data of its own, just the rules, so there's never a reason to create a "duel object".
  • Run loops through rounds. The || between its two TakeTurn calls uses short-circuiting: if the first automaton's turn knocks the other one down, the second turn never happens.
  • The switch statement has a block ({ … }) per case, so each case can declare its own roll variable without clashing. The default case throws, so that adding a new move to the enum and forgetting to handle it fails at once.
  • {report,-38} and {target.Name,-6} pad the text so the health bars line up in columns.

Step 4 · Start the fight#

Arena/Program.cs.NET consoleC#
using System;

namespace Arena
{
    class Program
    {
        static void Main()
        {
            var wren = new Automaton("Wren", maxHealth: 24, power: 6, armour: 1);
            var anvil = new Automaton("Anvil", maxHealth: 34, power: 5, armour: 2);
            var dice = new Dice(new[] { 4, 2, 6, 1, 5, 3, 6, 2, 4, 1, 3, 5 });

            Automaton winner = Duel.Run(wren, anvil, dice, maxRounds: 15);

            string result = winner == null ? "a draw" : $"a win for {winner.Name}";
            Console.WriteLine($"Result: {result}.");
        }
    }
}

Named arguments make the stats readable. Run it with dotnet run:

OutputText
=== Clockwork Arena: Wren vs Anvil ===
Round 1
  Wren strikes (rolled 4): 8 damage      Anvil  [########..] 26/34
  Anvil strikes (rolled 2): 6 damage     Wren   [########..] 18/24
Round 2
  Wren strikes (rolled 6): 10 damage     Anvil  [#####.....] 16/34
  Anvil strikes (rolled 1): 5 damage     Wren   [######....] 13/24
Round 3
  Wren braces and winds up               Anvil  [#####.....] 16/34
  Anvil braces and winds up              Wren   [######....] 13/24
Round 4
  Wren strikes (rolled 5): 4 damage      Anvil  [####......] 12/34
  Anvil strikes (rolled 3): 3 damage     Wren   [#####.....] 10/24
Round 5
  Wren strikes (rolled 6): 10 damage     Anvil  [#.........]  2/34
  Anvil braces and winds up              Wren   [#####.....] 10/24
Round 6
  Wren braces and winds up               Anvil  [#.........]  2/34
  Anvil OVERWINDS (rolled 2): 6 damage   Wren   [##........]  4/24
Round 7
  Wren braces and winds up               Anvil  [#.........]  2/34
  Anvil braces and winds up              Wren   [##........]  4/24
Round 8
  Wren strikes (rolled 4): 4 damage      Anvil  [..........]  0/34
Wren wins in round 8 with 4/24 health left.
Result: a win for Wren.

Follow round 4: Wren rolls a 5, so its strike should do 6 + 5 − 2 = 9, but Anvil braced in round 3 and takes only half, 4. In round 6, Anvil's Overwind would do 5 × 2 + 2 = 12, but Wren is bracing, so it's 6. If your output differs, compare it line by line: the first line that's different points to the rule you wrote differently.

Step 5 · What this has to do with Unity#

Nothing in Automaton, Dice or Duel mentions the console except the two Console.WriteLine lines in Duel. Swap those for Debug.Log and the same files run inside Unity unchanged; a MonoBehaviour could create the automatons in Start and call Duel.Run. Keeping game rules in plain C# classes, separate from the components that show them on screen, is one of the most useful habits in Unity programming: plain classes are easy to test, easy to reuse and easy to reason about. T11 · Clean Code, SOLID & a C# Style Guide for Unity and T18 · Debugging, Automated Tests & a Git Workflow for Unity build on it.

Going further#

Reading other people's code#

You'll spend more time reading code than writing it: Unity's samples, packages, answers online, your teammates' scripts. Read top-down: first the class's fields and the names of its public methods, which tell you what it's for, then the methods you care about. Look up every keyword you don't know (Microsoft's C# reference is excellent, and versioned). Run it and change one thing to see what happens.

Using the documentation#

For C#, use Microsoft's C# language reference and the .NET API browser, and check that a feature's C# version is 9 or lower. For Unity's types (Mathf, Debug, Vector3), use the Unity Scripting API for 6.3, as described in T00.

What's next in the language#

T04 · C# Beyond Basics: OOP, Generics, Collections, LINQ, Delegates & Memory continues with inheritance and interfaces, generics, collections such as Dictionary, LINQ, delegates and events, and what happens in memory. Those are the tools the Unity chapters lean on. If you're comfortable with everything here, you're ready.

Common mistakes & debugging#

It won't compile#

  • Missing f on a float literal (CS0664): float speed = 4.5;.
  • Wrong capitalisation: console.WriteLine, debug.log, string.length (it's Length).
  • A missing semicolon or brace reported on the line after the real mistake. Look one line up.
  • Using a variable outside its block (CS0103): it was declared inside an if or loop.
  • = instead of == in a condition: if (lives = 0) is an error in C# (it can't convert an int to a bool), which is lucky.
  • A newer C# feature that Unity doesn't support: check the version table.

It crashes#

  • NullReferenceException: a reference was never assigned. In Unity, nine times out of ten it's an Inspector field left empty. Double-click the error to find the line, then ask which reference on it is null.
  • IndexOutOfRangeException or ArgumentOutOfRangeException: an index equal to Length or Count, or a loop using <= instead of <.
  • InvalidOperationException: Collection was modified: adding to or removing from a List inside a foreach over it.
  • FormatException: int.Parse on text that isn't a number. Use TryParse.
  • StackOverflowException: a recursive method without a working base case, or a property that returns itself (public int Health => Health;).

It runs but gives the wrong answer#

  • Integer division: 3 / 4 is 0. Make one side a float.
  • Comparing floats with ==. Use a tolerance or Mathf.Approximately.
  • A string method whose result was thrown away: name.Trim(); on its own line.
  • Two variables sharing one array, list or object when you expected a copy.
  • Changing a copy of a struct (a Vector3 taken from somewhere) and expecting the original to change.
  • An else if chain in the wrong order, so a general case catches values meant for a specific one.
  • Rounding that surprises you: casting cuts off, and Round sends halves to even.

Exercises#

Do these in your playground project. The first three need only the first half of the chapter.

1 · Predict, then run

Write down what each line prints, then run them to check: 10 / 4, 10 / 4f, 10 % 4, (int)9.99f, Math.Round(4.5), "5" + 3, 5 + 3 + "5", 1 / 3 * 3.

Solution

2, 2.5, 2, 9, 4 (halfway goes to the even number), 53 (a string plus anything joins text), 85 (left to right: 5 + 3 is 8, then "8" + "5"), and 0 (1 / 3 is 0 in integers, and 0 × 3 is 0). If you got any wrong, re-read integer division and conversions: these exact mistakes cause real bugs.

2 · Fix the five errors

This program has five compile errors. Find them by reading first, then use the compiler to check.

Broken.csC#
using System;

class Program
{
    static void Main()
    {
        float speed = 2.5;
        int lives = 3
        string name = "Robin";
        if (lives > 0)
        {
            int bonus = 10;
        }
        Console.WriteLine(name + " has " + lives + bonus + " points");
        console.WriteLine("Done");
    }
Solution
  1. 2.5 needs an f: 2.5f (CS0664).
  2. int lives = 3 is missing its semicolon (CS1002).
  3. bonus is declared inside the if block and used outside it (CS0103). Declare it before the if.
  4. console should be Console (CS0103).
  5. The class's closing brace is missing at the end (CS1513).

The first time you compile, you'll see only two errors: the missing semicolon (CS1002) and the missing brace (CS1513). The compiler reports syntax errors (code it can't even read) before it checks names and types, so the other three only appear once those are fixed. That's normal, and it's why fixing the first reported error and compiling again is always a safe strategy.

3 · Bells and gulls

A harbour clock rings a bell every 3 minutes and a gull cries every 5. For minutes 1 to 15, print the minute number, or bell if it's a multiple of 3, gull if it's a multiple of 5, and storm if it's both. Put the decision in its own method.

Solution
BellsAndGulls/Program.cs.NET consoleC#
using System;

namespace BellsAndGulls
{
    class Program
    {
        static void Main()
        {
            for (int minute = 1; minute <= 15; minute++)
            {
                Console.WriteLine(Sound(minute));
            }
        }

        // Test "both" first: if the bell test came first, minute 15 would only ring the bell.
        static string Sound(int minute)
        {
            bool bell = minute % 3 == 0;
            bool gull = minute % 5 == 0;
            if (bell && gull) return "storm";
            if (bell) return "bell";
            if (gull) return "gull";
            return minute.ToString();
        }
    }
}
OutputText
1
2
bell
4
gull
bell
7
8
bell
gull
11
bell
13
14
storm

The order of the tests is the whole exercise. (This is a version of a famous interview question, FizzBuzz, which a surprising number of applicants get wrong.)

4 · Second best, in one pass

Write a method that returns the best (lowest) and second-best lap time from an array, without sorting it and visiting each element only once. Make it cope with ties and with arrays that are too short.

Solution
SecondBest/Program.cs.NET consoleC#
using System;

namespace SecondBest
{
    class Program
    {
        static void Main()
        {
            int[] lapTimes = { 62, 58, 71, 55, 60 };
            Console.WriteLine(Describe(lapTimes));
            Console.WriteLine(Describe(new[] { 40, 40, 45 }));
            Console.WriteLine(Describe(new[] { 30 }));
        }

        static string Describe(int[] times)
        {
            if (times.Length < 2)
            {
                return "Need at least two laps";
            }

            // One pass: keep the best and second best seen so far.
            int best = int.MaxValue;
            int second = int.MaxValue;
            foreach (int time in times)
            {
                if (time < best)
                {
                    second = best;   // the old best becomes second
                    best = time;
                }
                else if (time < second)
                {
                    second = time;
                }
            }
            return $"Best {best} s, second best {second} s";
        }
    }
}
OutputText
Best 55 s, second best 58 s
Best 40 s, second best 40 s
Need at least two laps

The trick is that when you find a new best, the old best becomes the second best. Starting both at int.MaxValue means the first values always replace them.

5 · A purse that can't go negative

Write a Purse class with a Coins property that other code can read but not set, a constructor, an Add method that refuses zero or negative amounts, and a TrySpend method that returns whether the purchase worked. Decide which mistakes should throw and which should return false.

Solution
Purse/Program.cs.NET consoleC#
using System;

namespace Shop
{
    // Only Purse's own methods can change Coins, so it can never go negative.
    public class Purse
    {
        public int Coins { get; private set; }

        public Purse(int startingCoins)
        {
            if (startingCoins < 0)
            {
                throw new ArgumentException("A purse can't start in debt.", nameof(startingCoins));
            }
            Coins = startingCoins;
        }

        public void Add(int amount)
        {
            if (amount <= 0)
            {
                throw new ArgumentException("Add a positive amount.", nameof(amount));
            }
            Coins += amount;
        }

        // Spending can fail for ordinary reasons, so it returns false instead of throwing.
        public bool TrySpend(int price)
        {
            if (price <= 0 || price > Coins)
            {
                return false;
            }
            Coins -= price;
            return true;
        }
    }

    class Program
    {
        static void Main()
        {
            var purse = new Purse(20);
            purse.Add(15);
            Console.WriteLine($"Coins: {purse.Coins}");
            Console.WriteLine($"Buy rope for 30: {purse.TrySpend(30)}, coins {purse.Coins}");
            Console.WriteLine($"Buy map for 40: {purse.TrySpend(40)}, coins {purse.Coins}");
            try
            {
                purse.Add(-5);
            }
            catch (ArgumentException)
            {
                Console.WriteLine("Add(-5) was refused");
            }
        }
    }
}
OutputText
Coins: 35
Buy rope for 30: True, coins 5
Buy map for 40: False, coins 5
Add(-5) was refused

Not having enough coins is a normal event in a game, so TrySpend returns false. Adding a negative amount is a programming mistake (no correct code would do it), so Add throws. That's the general rule: expected failures return a result, impossible requests throw.

6 · Struct or class?

For each, choose struct or class and give one reason: (a) a grid coordinate (column, row); (b) a player's inventory; (c) an RGBA colour; (d) a quest with a title, steps and a reward; (e) a damage number with an amount and a type.

Solution

(a) Struct: two small numbers that behave like a value, compared by content. (b) Class: one inventory that the UI, the shop and the save system all need to see the same copy of. (c) Struct: a small value (Unity's Color is one). (d) Class: it has an identity, changes over time, and is referred to from several places. (e) Struct is reasonable: small, short-lived, passed to a method and done with. If you hesitate, choose a class; the struct cases are the small, number-like ones.

7 · A fourth move: Repair

Add a Repair move to Clockwork Arena: it costs 3 spring and restores 5 health (never above the maximum). Make ChooseMove repair when health is below a third and the spring allows it. Run the duel and explain how the result changes.

Solution

Add Repair to the enum and a RepairCost constant. In Automaton:

Additions to Automaton.csC#
public const int RepairCost = 3;

public void Repair(int amount)
{
    Spend(RepairCost);
    Health += amount;   // the Health property clamps it to MaxHealth
}

// In ChooseMove, before the low-health brace rule:
if (health * 3 < MaxHealth && spring >= RepairCost) return Move.Repair;

The ChooseMove line must come before the low-health brace rule, or that rule catches most low-health turns first. In Duel.TakeTurn, add a case Move.Repair: that calls actor.Repair(5) and reports it. Notice that the default case would have thrown if you'd forgotten this step, which is exactly why it's there. The duel gets longer, and with these rolls it can change who wins. Balancing is a design question: try different costs and amounts and see how the fight feels (T28 · Game Design Fundamentals: Loops, MDA, Lenses, Balancing & Playtesting).

8 · Real dice, and balance

Give Dice a second constructor that takes a seed and uses System.Random to roll 1 to 6. Then run 1,000 duels with different seeds and print how often each automaton wins. Adjust their stats until each wins between 45% and 55% of the time.

Solution

One approach: keep a System.Random field that's null for scripted dice.

A seeded dice (sketch)C#
readonly System.Random random;

public Dice(int seed) => random = new System.Random(seed);

public int Roll()
{
    if (random != null)
    {
        return random.Next(1, 7);   // the upper bound is exclusive: 1 to 6
    }
    // ...the scripted rolls as before...
}

The rolls field must then be allowed to stay null, so the scripted constructor keeps its check and the seeded one skips it. In Main, loop 1,000 times, create fresh automatons and a new Dice(seed) each time, and count winners in two ints. Turn off the per-turn printing first (add a bool verbose parameter to Run), or you'll print 30,000 lines. A seed makes "random" runs repeatable, which is how games reproduce bugs and run replays. Note that the exact numbers System.Random produces for a seed may differ between .NET versions, so don't hard-code expected outputs for seeded runs.

Check yourself#

What does 7 / 2 evaluate to in C#?

  1. 3.5
  2. 3
  3. 4
  4. A compile error

Both operands are ints, so it's integer division, which drops the fraction. 7 / 2f would give 3.5.

Why is float speed = 4.5; a compile error?

  1. Floats can't hold fractions
  2. speed is a reserved word
  3. It needs var
  4. 4.5 is a double, which can't silently become a float

A literal with a decimal point is a double. Write 4.5f for a float.

Which is the safe way to check whether two floats are equal?

  1. Check that their difference is smaller than a small tolerance
  2. a == b
  3. (int)a == (int)b
  4. a.ToString() == b.ToString()

Floating-point results carry tiny errors, so exact equality often fails. Unity's Mathf.Approximately does the tolerance check for you.

In if (enemy != null && enemy.IsAlive), what happens when enemy is null?

  1. A NullReferenceException
  2. A compile error
  3. The right side isn't evaluated, so the condition is simply false
  4. The condition is true

&& short-circuits: once the left side is false, the answer is known.

An array has 5 items. Which index is the last one?

  1. 5
  2. 4
  3. -1
  4. It depends on the type

Indexes start at 0, so the last is Length - 1. Index 5 throws an IndexOutOfRangeException.

int[] b = a; b[0] = 9; What is a[0] now?

  1. Unchanged
  2. 0
  3. A compile error
  4. 9, because a and b refer to the same array

Arrays are reference types; assignment copies the reference, not the contents. Use Clone() for a copy.

Why does transform.position.x = 3f; fail to compile in Unity?

  1. position returns a copy of a Vector3 struct, so changing it would have no effect
  2. x is private
  3. Positions must be ints
  4. Transforms can't move

CS1612. Copy the position into a variable, change it, and assign it back.

What does out on a parameter mean?

  1. The parameter is optional
  2. The method can't change it
  3. The method must assign it, and the caller receives the value
  4. It's a copy that's discarded

out is how Try methods like int.TryParse hand back a result while returning success or failure.

Which of these won't compile in Unity 6.3?

  1. A switch expression with relational patterns
  2. A file-scoped namespace: namespace Game;
  3. An expression-bodied method
  4. String interpolation

File-scoped namespaces are C# 10. Unity 6.3 compiles C# 9; the other three are older.

When should code throw an exception rather than return false?

  1. Whenever anything fails
  2. Never, exceptions are always bad
  3. Only in Unity
  4. When a method is used in a way that correct code would never do

Expected failures (not enough coins, a typo in user input) return a result. Impossible requests (a negative price, an empty dice) are bugs and should fail loudly.

Cheat sheet#

Types and literals

  • int 3 · float 3.5f · double 3.5
  • bool true · char 'a' · string "a"
  • long 3L · decimal 3.5m
  • var x = 3; is still an int
  • const int Max = 3;

Maths traps

  • 7 / 2 → 3 · 7 / 2f → 3.5
  • 7 % 2 → 1 (remainder)
  • (int)7.9f → 7 (cuts off)
  • Math.Round(2.5) → 2 (to even)
  • Floats: compare with a tolerance
  • int overflow wraps silently

Control flow

  • if (…) { } else if (…) { } else { }
  • x switch { < 0 => …, _ => … }
  • for (int i = 0; i < n; i++)
  • foreach (var item in items)
  • while (…) · do { } while (…);
  • break leaves · continue skips

Collections

  • int[] a = { 1, 2 }; · a.Length
  • new int[4, 9] · GetLength(0)
  • List<T>: Add, Remove, Count
  • Last item: a[a.Length - 1]
  • Assigning shares; Clone() copies

Methods and classes

  • static int Sum(int a, int b) => a + b;
  • Optional: int width = 10
  • ref changes the caller's variable; out returns extra
  • public int Coins { get; private set; }
  • Fields private; start everything private
  • enum Mode { Off, On }

Unity specifics

  • Debug.Log, not Console.WriteLine
  • [SerializeField] private for Inspector fields
  • Copy, change, assign for Vector3
  • != null, not ?., on Unity objects
  • C# 9 only; no namespace X;
  • Fix the first Console error first

Glossary#

Compiler
The program that checks your C# and turns it into an assembly. Its complaints are compile errors.
Assembly
A compiled .dll file of intermediate language, which Mono runs or IL2CPP converts to native code.
Variable
A named place that holds a value of one type.
Type
What kind of value something is (int, string, Lantern), which decides what you can do with it.
Literal
A value written directly in code: 42, 4.5f, "hello", true.
Cast
An explicit conversion between types, written (int)value.
Scope
The region of code where a name exists: from its declaration to the end of its block.
String interpolation
Inserting values into a string with $"…{value}…", optionally with a format.
Short-circuit
How && and || skip their right side once the result is known.
Pattern
A test used in switch and is: a constant, a range like < 10, a type, or a combination.
Array
A fixed-size collection of values of one type, indexed from 0.
List
List<T>: a collection that can grow and shrink.
Method
A named block of code with parameters and a return type.
Parameter / argument
A method's declared input / the value passed for it in a call.
Overload
One of several methods with the same name and different parameter lists.
Class
A reference type that bundles data (fields) and behaviour (methods).
Object / instance
One thing created from a class with new, with its own copy of the fields.
Constructor
The method that runs when an object is created, to give it a valid starting state.
Property
A member that reads like a field but runs code on get and set.
Encapsulation
Keeping an object's data private and exposing only safe operations on it.
Static member
A field or method that belongs to the class itself rather than to each object.
Enum
A type with a fixed set of named values, stored as integers.
Value type
A type whose variables hold the value itself, so assignment copies it: numbers, bool, enums, structs.
Reference type
A type whose variables hold a reference to an object, so assignment shares it: classes, strings, arrays.
null
The value of a reference that points at no object.
Exception
An object thrown when something goes wrong at runtime, which travels up the calls until caught.