1.2 C# — the language of Unity

Phase 1 · Programming Foundations · Study time: 120–200 h

The primary language of Unity and HoYoverse's games. Syntax, classes and interfaces, generics, collections, LINQ, events and delegates, async/await, and how C# differs from C++ (garbage collection, value vs reference types).

1. What C# is, and why Unity uses it

C# (say it "C sharp") is a programming language made by Microsoft. Unity, the game engine used to build games like the ones from HoYoverse, uses C# for all its game code. So if you want to make Unity games, C# is the language you write every day.

You just finished learning C. C# looks a little like C on the surface (same curly braces, semicolons, int, if, for), but underneath it works very differently. The biggest difference is about memory. In C you asked for memory with malloc and gave it back with free, by hand. In C# you almost never do that. The language cleans up memory for you.

How C# runs (this is different from C)

When you compiled C, the compiler turned your code straight into machine code (the raw numbers the CPU runs). C# takes an extra step in the middle.

your code compiler runtime (Mono / IL2CPP) +-------------+ +------------+ +---------------------------+ | Program.cs | ----> | IL | ----> | JIT -> machine code | | (C# text) | | (bytecode) | | runs on the CPU | +-------------+ +------------+ | GC frees unused memory | +---------------------------+

Here is the smallest C# program that prints something. Do not worry about every word yet; we will explain each part later.

using System;

class Program
{
    static void Main()
    {
        Console.WriteLine("Hello from C#");
    }
}

Output:

Hello from C#

What happened: Console.WriteLine prints a line of text to the screen. Main is the method (a named block of code) where the program starts. In plain C you had main() too; C# has the same idea, it just lives inside a class (we will get to classes soon).

Tip The word using System; at the top means "let me use the tools in the System toolbox." Console lives in that toolbox. In C this is a bit like #include.

2. Variables and types

A variable is a named box that holds a value. A type tells C# what kind of value goes in the box (a whole number, a decimal, text, true/false). C# checks types for you, so you cannot accidentally put text where a number should go.

int hp = 100;          // whole number
float speed = 5.5f;    // decimal number (the f means "this is a float")
bool isAlive = true;   // true or false
char grade = 'A';      // a single character
string name = "Kimchi";// text (many characters)

Console.WriteLine(hp);
Console.WriteLine(speed);
Console.WriteLine(isAlive);
Console.WriteLine(name);

Output:

100
5.5
True
Kimchi

Notice isAlive printed as True with a capital T. That is just how C# shows a bool. Notice also the f after 5.5. C# needs it so it knows you mean a float and not a double (a bigger, more precise decimal type). Games usually use float because it is smaller and fast enough.

Letting C# guess the type with var

If the value on the right makes the type obvious, you can write var and C# figures out the type for you. The variable still has a fixed type — var is not "anything", it is just short-hand.

var count = 10;         // C# sees 10, so count is an int
var title = "Boss";     // C# sees text, so title is a string

Console.WriteLine(count);
Console.WriteLine(title);

Output:

10
Boss

3. The big idea: value types vs reference types

This is the most important idea in the whole chapter, and it connects straight to the pointers you just learned in C. In C# every type is one of two kinds:

Remember from the C chapter: the stack is fast, short-lived memory for local variables; the heap is a big pool for things that must live longer. A value type usually sits right on the stack. A reference type variable sits on the stack too, but the object it points to lives on the heap.

Copying a value type

Let us make a struct (a value type) and copy it.

struct PointStruct
{
    public int x;
    public int y;
}

// ... inside Main ...
PointStruct a;
a.x = 1;
a.y = 2;

PointStruct b = a;   // this copies the WHOLE thing
b.x = 99;            // change b only

Console.WriteLine(a.x);   // still 1
Console.WriteLine(b.x);   // 99

Output:

1
99

When you wrote PointStruct b = a;, C# made a full, separate copy of a. Changing b.x did not touch a. They are two independent boxes.

STACK +---------------------------+ | a | x = 1 | y = 2 | +---------------------------+ | b | x = 99 | y = 2 | <- b is its own copy; changing b left a alone +---------------------------+

Copying a reference type

Now the same shape, but as a class (a reference type). Watch what changes.

class PointClass
{
    public int x;
    public int y;
}

// ... inside Main ...
PointClass c = new PointClass();   // new makes an object on the heap
c.x = 1;
c.y = 2;

PointClass d = c;   // this copies the REFERENCE, not the object
d.x = 99;           // change through d...

Console.WriteLine(c.x);   // 99  (!!)
Console.WriteLine(d.x);   // 99

Output:

99
99

Surprise: changing d.x also changed c.x. Why? Because c and d do not hold the object. They both hold a reference that points to the same object on the heap. It is exactly like two pointers in C holding the same address. Change what the address points to, and both "see" the change.

STACK HEAP +-------------+ +----------------------+ | c : ref ----+---------------> | PointClass object | +-------------+ +----> | x = 99 | | d : ref ----+----------+ | y = 2 | +-------------+ +----------------------+ both c and d hold the SAME address, so they see one shared object

The word new is what creates the object on the heap and hands you back a reference to it. In C you did this with malloc. In C# you write new and never call free — the garbage collector frees the object later, once nothing points to it anymore.

Common mistake Beginners think d = c makes a copy of the object. For a class it does not — it only copies the reference. If you truly want a separate object, you must make a new one and copy the fields yourself.

4. null and the NullReferenceException

A reference type can point to nothing. That "nothing" is called null. It means "this variable does not point to any object yet." In C this was the NULL pointer.

If you try to use an object through a reference that is null, the program crashes with a NullReferenceException. This is probably the most common crash a new Unity programmer hits.

PointClass p = null;      // points to nothing
Console.WriteLine(p.x);   // CRASH: there is no object to read x from

Output:

Unhandled exception. System.NullReferenceException:
Object reference not set to an instance of an object.

The fix is to check for null before you use the object.

PointClass p = null;

if (p != null)
{
    Console.WriteLine(p.x);
}
else
{
    Console.WriteLine("p is null, nothing to read");
}

Output:

p is null, nothing to read

C# also has a short way to say "only use it if it is not null" — the ?. operator (called the null-conditional operator).

PointClass p = null;
Console.WriteLine(p?.x);   // if p is null, this gives null instead of crashing

Output:


It printed a blank line because p?.x returned null (there was no object), and printing null shows nothing. No crash. That ?. is very handy in real game code.

5. Classes and objects

A class is a blueprint. An object is one thing built from that blueprint. If Enemy is a class, then one particular slime on screen is an object of that class. A class can hold:

class Enemy
{
    public string name;   // field
    public int hp;        // field

    // constructor: runs when we write "new Enemy(...)"
    public Enemy(string startName, int startHp)
    {
        name = startName;
        hp = startHp;
    }

    // method: an action the enemy can do
    public void TakeDamage(int amount)
    {
        hp = hp - amount;
        Console.WriteLine(name + " took " + amount + " damage, hp = " + hp);
    }
}

// ... inside Main ...
Enemy slime = new Enemy("Slime", 30);
slime.TakeDamage(10);
slime.TakeDamage(25);

Output:

Slime took 10 damage, hp = 20
Slime took 25 damage, hp = -5

The constructor ran once (setting name to "Slime" and hp to 30). Then each call to TakeDamage changed the object's own hp. Notice hp went to -5 — nothing stopped it. That is where properties help.

Properties: guarding a field

A property looks like a field from the outside but runs code when you read or write it. Here we stop hp from ever going below 0.

class Player
{
    private int hp;   // private: only this class can touch it directly

    public int Hp
    {
        get { return hp; }        // runs when someone reads Hp
        set
        {
            if (value < 0) hp = 0;   // "value" is the incoming number
            else hp = value;
        }
    }
}

// ... inside Main ...
Player player = new Player();
player.Hp = -50;                // the set block clamps it
Console.WriteLine(player.Hp);   // reads through get
player.Hp = 80;
Console.WriteLine(player.Hp);

Output:

0
80

Writing player.Hp = -50; quietly became 0 because the set block checked first. The word value is the number being assigned. This is the whole point of properties: they look simple to use but let you add rules.

If you do not need any rules, C# has a short "auto-property" that makes the hidden field for you:

class Item
{
    public string Name { get; set; }
    public int Price { get; set; }
}

// ... inside Main ...
Item sword = new Item();
sword.Name = "Iron Sword";
sword.Price = 120;
Console.WriteLine(sword.Name + " costs " + sword.Price);

Output:

Iron Sword costs 120

6. Interfaces and inheritance

These two features let different classes share behavior. Both are used constantly in game code.

Inheritance: build on top of another class

Inheritance means a class can be based on another class and get all its fields and methods for free, then add more. We say the new class "inherits from" or "extends" the old one.

class Character
{
    public string name;

    public void SayName()
    {
        Console.WriteLine("I am " + name);
    }
}

// Mage inherits everything from Character, then adds CastSpell
class Mage : Character
{
    public void CastSpell()
    {
        Console.WriteLine(name + " casts Fireball");
    }
}

// ... inside Main ...
Mage m = new Mage();
m.name = "Lila";
m.SayName();     // came from Character
m.CastSpell();   // added by Mage

Output:

I am Lila
Lila casts Fireball

The : Character part means "Mage is a Character." So a Mage already knows how to SayName without repeating that code. A base method can be marked virtual so a child class can replace it with override, but we will keep it simple here.

Interfaces: a promise of behavior

An interface is a list of methods a class promises to have, with no code inside. It is a contract. Any class that says "I implement this interface" must provide those methods. This lets you treat very different objects the same way, as long as they keep the promise.

interface IDamageable
{
    void TakeDamage(int amount);   // just the promise, no body
}

class Barrel : IDamageable
{
    public void TakeDamage(int amount)
    {
        Console.WriteLine("The barrel breaks into pieces!");
    }
}

class Goblin : IDamageable
{
    public void TakeDamage(int amount)
    {
        Console.WriteLine("Goblin yells and loses " + amount + " hp");
    }
}

// ... inside Main ...
IDamageable target1 = new Barrel();
IDamageable target2 = new Goblin();
target1.TakeDamage(10);
target2.TakeDamage(10);

Output:

The barrel breaks into pieces!
Goblin yells and loses 10 hp

Both objects are stored in a variable of type IDamageable, and both understand TakeDamage, even though a barrel and a goblin are nothing alike. In a real game your sword can just say "if the thing I hit is IDamageable, call TakeDamage" and it works for barrels, goblins, doors, anything. Interface names usually start with a capital I by habit.

7. Generics and collections

Very often you need a list of things: all enemies on screen, all items in a bag. C# gives you ready-made collections for this. The most useful two are List<T> and Dictionary<TKey, TValue>.

The <T> part is a generic. Generic means "works with any type, but you pick which one." List<int> is a list of ints; List<string> is a list of strings. The T is a placeholder you fill in. This keeps it type-safe: a List<int> will refuse to hold text.

List<T> — an array that can grow

List<string> party = new List<string>();
party.Add("Kimchi");
party.Add("Miso");
party.Add("Tofu");

Console.WriteLine(party.Count);   // how many items
Console.WriteLine(party[0]);      // read by position, starts at 0

foreach (string member in party) // visit each item in turn
{
    Console.WriteLine(member);
}

Output:

3
Kimchi
Kimchi
Miso
Tofu

A List is like a C array but it can grow with Add, and it knows its own Count. You read an item by its index with party[0] (the first item is index 0, same as C arrays). The foreach loop walks through every item without you managing an index by hand.

Dictionary<TKey, TValue> — look up by a key

A dictionary stores pairs: a key and a value. You look up the value using the key. Think of a scoreboard: the player's name is the key, their score is the value.

Dictionary<string, int> scores = new Dictionary<string, int>();
scores["Kimchi"] = 10;
scores["Miso"] = 25;

Console.WriteLine(scores["Miso"]);   // look up Miso's score

if (scores.ContainsKey("Tofu"))
{
    Console.WriteLine(scores["Tofu"]);
}
else
{
    Console.WriteLine("no score for Tofu yet");
}

Output:

25
no score for Tofu yet

Looking up scores["Miso"] instantly gives 25. Always check ContainsKey before reading a key that might not be there — reading a missing key throws an exception (a crash), which we will meet in section 11.

8. Delegates and events

This section matters a lot for Unity, because Unity uses it everywhere for input and UI (when a button is clicked, when the player takes damage, and so on).

A delegate is a variable that holds a function. In C you had function pointers; a delegate is the friendly C# version. You can store a function in it and call it later.

// a delegate TYPE: something that takes an int and returns nothing
delegate void DamageHandler(int amount);

class Program
{
    static void PrintDamage(int amount)
    {
        Console.WriteLine("Damage dealt: " + amount);
    }

    static void Main()
    {
        DamageHandler handler = PrintDamage;  // store the function
        handler(15);                          // call it through the variable
    }
}

Output:

Damage dealt: 15

The variable handler holds the function PrintDamage. Calling handler(15) is the same as calling PrintDamage(15). Being able to pass a function around like data is what makes the next idea possible.

Events: "tell me when something happens"

An event is a way for one object to announce "something happened!" and for other objects to listen and react. The object that announces does not need to know who is listening. C# has a built-in delegate called Action (a function that takes no arguments and returns nothing) that we can use for the listeners.

class Button
{
    public event Action OnClick;   // a list of listeners

    public void Press()
    {
        Console.WriteLine("Button pressed");
        if (OnClick != null)   // is anyone listening?
        {
            OnClick();         // tell every listener
        }
    }
}

// ... inside Main ...
Button b = new Button();

// += means "add me as a listener". These are lambdas (tiny inline functions).
b.OnClick += () => Console.WriteLine("Play click sound");
b.OnClick += () => Console.WriteLine("Open the menu");

b.Press();

Output:

Button pressed
Play click sound
Open the menu

Two listeners subscribed with +=. When Press fired OnClick(), both listeners ran, in the order they were added. The () => ... part is a lambda (a small unnamed function written right where you need it). This "subscribe and get told later" pattern is exactly how Unity handles UI buttons and input, so it is worth remembering.

9. A taste of LINQ

LINQ (Language Integrated Query) is a set of tools for asking questions about a collection: filter it, sort it, count it, transform it — in one short line. You get it by adding using System.Linq; at the top.

using System;
using System.Collections.Generic;
using System.Linq;

// ... inside Main ...
List<int> numbers = new List<int> { 4, 9, 2, 15, 7, 20 };

// keep only the numbers bigger than 8
List<int> big = numbers.Where(n => n > 8).ToList();

foreach (int n in big)
{
    Console.WriteLine(n);
}

Output:

9
15
20

Where(n => n > 8) reads as "keep each n where n is greater than 8." The n => n > 8 is a lambda again — a tiny test applied to every item. ToList() collects the survivors into a new list. Without LINQ you would write a for loop and an if; LINQ says the same thing in one line.

Another quick one — count how many numbers are even:

int evenCount = numbers.Count(n => n % 2 == 0);
Console.WriteLine(evenCount);   // 4, 2, and 20 are even

Output:

3
Common mistake LINQ is lovely but each call quietly creates new objects. Running heavy LINQ every single frame in a game can create garbage and cause stutter (see section 12). It is great for setup and menus; be careful in the per-frame hot path.

10. async and await (just the idea)

Some things are slow: loading a big file, downloading data from a server, waiting a moment. If your program just sat and waited, the whole game would freeze for that time. async/await lets you wait for a slow thing without freezing everything else.

using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        Console.WriteLine("Start loading...");
        await Task.Delay(1000);   // wait 1 second (1000 ms) without freezing
        Console.WriteLine("Done loading!");
    }
}

Output (the second line appears about one second after the first):

Start loading...
Done loading!

Task.Delay(1000) is a stand-in for any slow job. The await pauses Main at that line, and one second later it picks up where it left off and prints the second line. For now, just remember the shape: async on the method, await in front of the slow call. You will use this in Unity for things like loading a level in the background.

11. Exceptions: try / catch

When something goes wrong at runtime (a null reference, an index out of range, a bad number), C# throws an exception. If nobody catches it, the program crashes. A try / catch block lets you catch the error and keep running.

try
{
    int[] nums = { 1, 2, 3 };
    Console.WriteLine(nums[10]);   // there is no index 10 -> throws
}
catch (IndexOutOfRangeException e)
{
    Console.WriteLine("Caught a problem: " + e.Message);
}

Console.WriteLine("The program keeps running");

Output:

Caught a problem: Index was outside the bounds of the array.
Program keeps running

The code in try ran until nums[10] failed. Instead of crashing, control jumped to the catch block, which printed a friendly message using e.Message (the exception's description). Then the program carried on. Use try/catch around things that can fail for reasons outside your control (reading a file, parsing user text). Do not use it to hide bugs you should just fix.

12. How C# differs from C++ for a game programmer

Many big engines and games use C++. Unity games use C#. The single biggest day-to-day difference is memory, and it cuts both ways.

The good side: no manual free

In C and C++ you call free or delete yourself. Forget to, and you leak memory. Do it twice, or use a pointer after freeing it, and you get nasty bugs (dangling pointers, crashes). In C# the garbage collector handles all of that. You write new and simply stop using the object; the GC frees it later. Whole classes of C/C++ bugs just disappear.

The catch: GC spikes cause stutter

The garbage collector is not free. Every time you use new to make an object on the heap, you create a little garbage for later. When enough piles up, the GC stops your game for a moment to clean it. In a document that pause is nothing. In a game running 60 frames per second, a pause of even a few milliseconds shows up as a visible stutter (a hitch in the smooth motion). This is called a GC spike.

frame time (each bar is one frame, taller = slower) | | | | | | | |||||| | | | | | | | | | | | |||||| | | | | <- the tall bar is a GC spike: smooth ............... STUTTER ......smooth the frame took too long

The fix is to not create garbage in the hot path (the code that runs every frame). Two common habits:

You do not need to master this now. Just plant the idea: in C++ you worry about freeing memory; in C# you worry about not creating too much of it too often. We will come back to pooling in a later chapter.

13. A short bridge to Unity

Everything above is plain C#. Now, where does it land in Unity? Unity scripts are C# classes that inherit from a special base class called MonoBehaviour. When your class inherits from MonoBehaviour, you can attach it to an object in your game (a player, an enemy), and Unity will call certain methods for you automatically. You do not write Main — Unity is the one calling.

Two of the most important methods Unity calls:

using UnityEngine;

public class PlayerMover : MonoBehaviour
{
    public float speed = 5f;

    void Start()
    {
        Debug.Log("Player is ready");   // Debug.Log is Unity's Console.WriteLine
    }

    void Update()
    {
        // read the left/right input, scaled so it is smooth on any machine
        float move = Input.GetAxis("Horizontal") * speed * Time.deltaTime;
        transform.Translate(move, 0f, 0f);   // move this object left/right
    }
}

What to notice: there is no Main. Unity sees this class attached to a game object, calls Start once, then calls Update every frame. Time.deltaTime is the time since the last frame; multiplying by it keeps movement the same speed on a fast or slow computer. Because Update runs so often, this is exactly the "hot path" from section 12 — the place where you must avoid needless new and heavy LINQ.

That is the whole picture: C# is the language, and MonoBehaviour with Update is where your C# actually runs inside a game. The deep Unity details come in a later chapter; for now you have the language under your belt.

Glossary

Exercises

Exercise 1 Predict the output. We have a struct and a class with the same fields. What does this print, and why?
struct SBox { public int n; }
class  CBox { public int n; }

// ... inside Main ...
SBox s1;  s1.n = 5;
SBox s2 = s1;   s2.n = 100;

CBox c1 = new CBox();  c1.n = 5;
CBox c2 = c1;   c2.n = 100;

Console.WriteLine(s1.n);
Console.WriteLine(c1.n);
Show answer

Output:

5
100

SBox is a struct, a value type. SBox s2 = s1; made a full copy, so changing s2.n to 100 left s1.n at 5.

CBox is a class, a reference type. CBox c2 = c1; copied only the reference, so c1 and c2 point to the same object on the heap. Changing c2.n to 100 also changed what c1 sees. This is the section 3 idea in one puzzle.

Exercise 2 Write a class HealthBar with a property Value that never goes below 0 or above 100. Reading it should just return the stored number. Then show that setting it to 150 stores 100, and setting it to -30 stores 0.
Show answer
class HealthBar
{
    private int value;

    public int Value
    {
        get { return value; }
        set
        {
            if (value > 100) this.value = 100;
            else if (value < 0) this.value = 0;
            else this.value = value;
        }
    }
}

// ... inside Main ...
HealthBar hb = new HealthBar();
hb.Value = 150;
Console.WriteLine(hb.Value);
hb.Value = -30;
Console.WriteLine(hb.Value);

Output:

100
0

The set block checks the incoming value and clamps it before storing. We wrote this.value to mean "the field" because the field and the keyword value share a name here. This is exactly why properties exist: they look like a plain field but can enforce rules.

Exercise 3 You have a list of enemy hp values. Using LINQ, print how many enemies are still alive (hp greater than 0), then print only the alive hp values.
List<int> hps = new List<int> { 12, 0, 5, 0, 30, 0, 8 };
Show answer
using System;
using System.Collections.Generic;
using System.Linq;

// ... inside Main ...
List<int> hps = new List<int> { 12, 0, 5, 0, 30, 0, 8 };

int aliveCount = hps.Count(h => h > 0);
Console.WriteLine("Alive: " + aliveCount);

List<int> aliveHps = hps.Where(h => h > 0).ToList();
foreach (int h in aliveHps)
{
    Console.WriteLine(h);
}

Output:

Alive: 4
12
5
30
8

Count(h => h > 0) counts items where hp is above 0 (there are four). Where(h => h > 0) keeps only those items, and ToList() collects them so we can loop and print. Same filter idea as section 9, applied to a real game-ish question.

← Back to all chapters