4.2 C# Scripting in Unity

Phase 4 · Unity (primary engine) · Study time: 60–100 h

The MonoBehaviour lifecycle (Awake, Start, Update, FixedUpdate), coroutines, events, and ScriptableObjects — writing the actual behavior of your game.

You already know C# on its own: classes, objects, generics, collections. Unity does not run plain C# classes as scripts — it runs classes that inherit from a special base class called MonoBehaviour. This chapter shows what that gives you: methods Unity calls for you automatically at exact moments (the "lifecycle"), fields that turn into editable boxes in the Editor, and the everyday tools almost every Unity script uses — spawning objects, reading input, and running code that plays out over several seconds instead of finishing in one line.

1. MonoBehaviour: The Base Class Every Script Uses

Every script attached to a GameObject (an object placed in a Unity scene) is a normal C# class — except it inherits from MonoBehaviour, a class Unity itself provides. Inheriting from it does two things: it lets you drag the script onto a GameObject in the Editor, and it gives your class a set of special methods Unity calls automatically, without you ever calling them yourself.

using UnityEngine;

public class Player : MonoBehaviour
{
    // This class does nothing extra yet, but because it inherits
    // from MonoBehaviour, Unity already treats it as a "live" script:
    // you can drag this file onto any GameObject in the Hierarchy.
}

What happens: nothing prints to the Console yet, since the class body is empty. But in the Editor, this script now appears as a component you can attach: select a GameObject, drag Player.cs onto it in the Inspector panel, and Unity creates one instance of this class for that object. From here on, Unity is in charge of creating, running, and eventually destroying that instance — you never write new Player() yourself.

Compare this to a plain C# class you might have written before, like a small math helper or a linked-list node: you construct those yourself with new, and nothing calls their methods unless you call them. A MonoBehaviour is different. Once it is attached to a GameObject that exists in a running scene, Unity's engine loop calls specific methods on it automatically, at specific points every single frame. Those methods are the subject of the rest of this chapter.

Tip A GameObject is just a container: a name, a position/rotation/scale (its Transform), and a list of components attached to it. A MonoBehaviour script is one kind of component. A single GameObject can hold many components at once — a Rigidbody for physics, a Collider for collision, your own script, and so on.

2. The Script Lifecycle: An Overview

Because Unity calls your methods for you, you need to know exactly which methods exist and when each one runs. Together these are called the "lifecycle" of a script — the order of events from the moment an object is created to the moment it is destroyed. Get the order wrong in your head and you will write bugs that only show up sometimes, like code that reads a value before another script has had a chance to set it up.

The big picture

Object created / scene loads | v Awake() (once - set up internal state, cache references) | v OnEnable() (once now, and again every time re-enabled later) | v Start() (once - right before this object's very first frame) | v ================================ PER-FRAME LOOP (repeats) ================================ | v FixedUpdate() / Update() / LateUpdate() -- see the next diagram | v (back to the top of the PER-FRAME LOOP, next frame) | v OnDisable() (object turned off, or right before it is destroyed) | v OnDestroy() (object destroyed - final cleanup)

Every one of these is just a method name you write inside your own class. You do not have to implement all of them — Unity only calls the ones you actually define. A script with no Update method simply never receives an Update call; there is no cost to leaving one out.

Zooming into one frame

One rendered frame, from Unity's point of view: FixedUpdate() -- runs 0, 1, or more times this frame (fixed clock: every 0.02s of game time by default, i.e. 50 times per second) | v Update() -- runs exactly once per frame (input reading, gameplay logic, timers, non-physics movement go here) | v LateUpdate() -- runs exactly once per frame, after every | object's own Update() has already run v Unity renders the frame to the screen

The next few sections go through each of these methods one at a time, with runnable code for each.

3. Awake, OnEnable, and Start: Setup Order

These three methods all run once near the beginning of an object's life, but at slightly different moments, and mixing them up is a very common beginner bug.

Awake()

Awake runs once, as early as possible — when the object is created (when the scene loads, or right when something calls Instantiate at runtime). It runs even if the script's own enabled checkbox is unticked in the Inspector. Use it for setup that only depends on the object itself: caching component references (section 9 covers this), setting default internal values.

OnEnable()

OnEnable runs once right after Awake, and then again every single time the object becomes active after being turned off — for example if another script calls gameObject.SetActive(true) after it had been set to false. Use it for anything you want reset every time the object turns back on, such as subscribing to an event.

Start()

Start runs once, right before the object's first Update. The important difference from Awake: Unity finishes calling Awake on every object in the scene first, and only then starts calling Start on any of them. That means inside Start you can safely assume every other object's Awake has already run — useful when one script needs to read something another script set up in its own Awake.

using UnityEngine;

public class LifecycleLogger : MonoBehaviour
{
    void Awake()
    {
        Debug.Log("Awake");
    }

    void OnEnable()
    {
        Debug.Log("OnEnable");
    }

    void Start()
    {
        Debug.Log("Start");
    }
}

What happens: attach this script to any GameObject and press Play. The Console prints, in this exact order:

Awake
OnEnable
Start

This order — Awake, then OnEnable, then Start — never changes for a normal object that starts active in the scene. It is worth memorizing.

Common mistake If the GameObject itself is turned off in the Hierarchy (the checkbox next to its name is unticked) when the scene loads, none of these three methods run yet — not even Awake — because a disabled GameObject is completely asleep. All three fire together the moment something calls SetActive(true) on it. This is different from the component's own enabled checkbox: with that, Awake still runs immediately, but OnEnable and Start wait until the component is enabled.

4. Update: Running Every Frame

Update is the method you will use the most. Unity calls it once per rendered frame — and frames do not take a fixed amount of time. A fast gaming PC might render 300 frames every second; a phone under heavy load might render only 20. Update runs once each time, no matter how long that frame took.

using UnityEngine;

public class Spinner : MonoBehaviour
{
    void Update()
    {
        transform.Rotate(0f, 2f, 0f); // turn 2 degrees every call to Update
    }
}

What happens: attach this to a cube and press Play — it spins. But watch closely: on a machine rendering 60 frames per second, Update is called 60 times every second, so the cube turns 60 x 2 = 120 degrees per second. On a slower machine rendering only 30 frames per second, the exact same code turns the cube just 30 x 2 = 60 degrees per second. The same script runs at a different visual speed depending on how fast the computer is. That is a bug — section 7 fixes it.

For now, remember what belongs in Update: reading input, gameplay logic, timers, anything that is not physics. Physics goes in the next method.

5. FixedUpdate: Physics on a Fixed Clock

FixedUpdate differs from Update in one key way: it does not run once per rendered frame. It runs on its own fixed clock, by default every 0.02 seconds of game time — exactly 50 times per second — no matter how fast or slow the computer is actually rendering. Unity's physics engine (Rigidbody, colliders, forces) needs this kind of steady, predictable timestep to stay accurate and consistent, so anything that touches physics belongs here, not in Update.

using UnityEngine;

public class ConstantThrust : MonoBehaviour
{
    public float thrust = 10f;
    Rigidbody rb;

    void Awake()
    {
        rb = GetComponent<Rigidbody>();
    }

    void FixedUpdate()
    {
        rb.AddForce(Vector3.forward * thrust);
    }
}

What happens: attach this to an object with a Rigidbody component and press Play — it accelerates forward smoothly. Because FixedUpdate runs on its own clock, it does not always line up one-to-one with rendered frames: at a steady 50 FPS it lines up almost exactly one call per frame; at 100 FPS, roughly every other frame has no FixedUpdate call at all; at 20 FPS, Unity calls FixedUpdate two or three times in a row to "catch up" before the next frame is drawn. This is exactly the "0, 1, or more times" from the diagram in section 2.

Tip You can change how often FixedUpdate runs from Project Settings, under Time, in the Fixed Timestep field. The default, 0.02 seconds, is a good balance between physics accuracy and CPU cost for most games.

6. LateUpdate: The Cleanup Pass

LateUpdate also runs once per frame, but always after every object's Update has already finished for that frame. Unity does not guarantee what order different scripts' Update methods run in relative to each other — your player-movement script and your camera script might run in either order on any given frame. LateUpdate exists to solve exactly that problem for things like a camera that must always react to where the player ended up, not where the player happened to be a moment ago.

using UnityEngine;

public class CameraFollow : MonoBehaviour
{
    public Transform target;
    public Vector3 offset = new Vector3(0f, 5f, -10f);

    void LateUpdate()
    {
        transform.position = target.position + offset;
    }
}

What happens: attach this to the Main Camera, drag the player's Transform into target, and press Play — the camera smoothly stays behind and above the player. If this code ran in Update instead, there would be frames where the camera happens to update before the player moves, showing the player's old position for one frame — a small but visible jitter, especially at low frame rates. Running it in LateUpdate guarantees the player has already finished moving this frame before the camera copies its position.

7. Time.deltaTime: Frame-Rate Independent Movement

Section 4 showed the spinner bug: the exact same code turns an object at different real-world speeds depending on frame rate. The fix is Time.deltaTime — a number Unity hands you every frame equal to how many real seconds passed since the previous frame (a small number: about 0.0166 at 60 FPS, about 0.0333 at 30 FPS).

using UnityEngine;

public class Spinner : MonoBehaviour
{
    public float degreesPerSecond = 120f;

    void Update()
    {
        transform.Rotate(0f, degreesPerSecond * Time.deltaTime, 0f);
    }
}

What happens: now the cube always turns 120 degrees every real second, no matter the frame rate.

Without Time.deltaTime (rotate a fixed 2 degrees every Update call): 60 FPS: 60 calls/sec x 2 degrees = 120 degrees per second 30 FPS: 30 calls/sec x 2 degrees = 60 degrees per second (slower!) With Time.deltaTime (rotate speed * deltaTime per Update call): 60 FPS: deltaTime ~ 0.0166s, 60 calls/sec x (120 x 0.0166) ~ 120 deg/sec 30 FPS: deltaTime ~ 0.0333s, 30 calls/sec x (120 x 0.0333) ~ 120 deg/sec (same speed either way!)

The trick: a "fixed amount per call" becomes a "fixed amount per second" once you multiply it by however much of a second that particular call actually covers. A frame that took twice as long gets a deltaTime twice as big, so it moves the object twice as far to compensate — the total distance covered per real second stays the same.

This is the exact same idea you already used when interpolating between two values, blending from a start value to an end value with a fraction t between 0 and 1. There, t had to grow at a steady rate over real time, not over frame count, or the blend would speed up and slow down with the frame rate too. Time.deltaTime is how you build that steady growth: each frame you add a small slice of real elapsed time, t += Time.deltaTime / duration, instead of a fixed slice per call. Frame-rate independent movement and frame-rate independent interpolation are solved by the same rule: never assume how much time one "step" represents — always ask Time.deltaTime how much time actually passed.

Tip FixedUpdate does not need this trick for physics forces, because its timestep is already fixed and predictable — that is the entire reason it exists. You will still see Time.fixedDeltaTime used inside FixedUpdate occasionally, but for a plain AddForce call like the one in section 5, Unity's physics engine already accounts for the fixed timestep for you.

8. Exposing Fields to the Inspector: public and [SerializeField]

Unity automatically turns certain fields on a MonoBehaviour into editable boxes in the Inspector panel — no extra UI code required. There are two ways to do this:

using UnityEngine;

public class Health : MonoBehaviour
{
    public int maxHealth = 100;

    [SerializeField]
    private int currentHealth = 100;
}

What happens: select a GameObject with this script attached, and the Inspector shows two editable fields, "Max Health" and "Current Health" (Unity turns the camelCase field name into a readable label automatically), both starting at 100, both editable by hand or by a designer without touching code.

The difference between the two shows up outside the Inspector, in other scripts:

Tip Default to [SerializeField] private. Only make a field public when other scripts genuinely need to read or write it directly — and even then, a public method (like TakeDamage(int amount)) is usually safer than a raw public field, because it lets you control exactly how the value is allowed to change.

9. GetComponent and Caching References

GetComponent<T>() searches a GameObject for a component of type T and returns it (or null if there is none). It is how one script reaches another component sitting on the same object — for example, a movement script reaching the object's Rigidbody.

The problem

using UnityEngine;

public class BadMover : MonoBehaviour
{
    void Update()
    {
        // Do not do this: GetComponent searches all over again,
        // every single frame, for a component that never changes.
        GetComponent<Rigidbody>().AddForce(Vector3.up);
    }
}

What happens: this works correctly, but GetComponent is not instant. It walks the list of components attached to the GameObject, comparing types until it finds a match. Doing that 60 or more times a second, for a result that is exactly the same every single time, wastes CPU time your game could spend on things that actually change frame to frame.

The fix: cache it in Awake

using UnityEngine;

public class GoodMover : MonoBehaviour
{
    Rigidbody rb; // cached reference, found once

    void Awake()
    {
        rb = GetComponent<Rigidbody>(); // search happens ONCE, here
    }

    void Update()
    {
        rb.AddForce(Vector3.up); // reuse the stored reference, no searching
    }
}

What happens: identical behavior in the game, but the search only ever runs once, in Awake (which section 3 showed always runs before Update). Every later frame just reuses the field rb, which is basically free.

Common mistake Calling GetComponent inside Update is one of the most common beginner performance mistakes. It is harmless in a tiny test scene with one object, but the cost adds up fast once a scene has hundreds of objects all doing it every frame. Cache in Awake (or Start), and use the cached field everywhere else.

10. Instantiate and Destroy: Spawning and Removing Objects

Most games create and remove objects constantly while running — bullets, enemies, pickup items, explosion effects. Instantiate creates a new copy of an existing object at runtime; Destroy removes an object. Both work on a template called a prefab: a GameObject you set up once in the Editor (model, scripts, components, all configured) and save as a reusable asset in your Project window, so you can stamp out as many copies of it as you want at runtime.

using UnityEngine;

public class Gun : MonoBehaviour
{
    public GameObject bulletPrefab; // drag the Bullet prefab here in the Inspector
    public Transform muzzle;        // an empty child Transform at the gun's tip

    void Update()
    {
        if (Input.GetButtonDown("Fire1"))
        {
            GameObject bullet = Instantiate(bulletPrefab, muzzle.position, muzzle.rotation);
            Destroy(bullet, 3f); // remove this exact bullet after 3 seconds
        }
    }
}

What happens: every time the Fire1 button (left mouse button, by default) is pressed, Instantiate creates one new live copy of the bullet prefab in the scene, positioned and rotated exactly like muzzle. That new bullet is a completely independent GameObject from that point on — moving it does not move the prefab asset, and firing again creates yet another separate copy. Three seconds later, Destroy(bullet, 3f) automatically removes that specific bullet from the scene.

Destroy(bullet) on its own, with no delay, does not remove the object instantly mid-line — it marks the object for removal at the end of the current frame. That small delay is intentional: other code that already grabbed a reference to bullet earlier in the same frame will not suddenly find itself holding a half-deleted object.

Common mistake Forgetting to destroy spawned objects is a classic cause of a game slowly grinding to a halt: bullets, particle effects, or enemies pile up forever because nothing ever calls Destroy on them. Whenever you write an Instantiate call, immediately ask yourself: when and how will this object be destroyed?

11. Reading Player Input

Unity's classic Input class is the simplest way to read the keyboard, mouse, and gamepads. Three methods cover most needs:

using UnityEngine;

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

    void Update()
    {
        float h = Input.GetAxis("Horizontal"); // -1 (A / Left) to +1 (D / Right)
        float v = Input.GetAxis("Vertical");   // -1 (S / Down) to +1 (W / Up)

        Vector3 move = new Vector3(h, 0f, v) * speed * Time.deltaTime;
        transform.Translate(move);
    }
}

What happens: holding D moves the object in the positive X direction, holding W moves it in the positive Z direction, and both together move it diagonally — all at a steady speed units per second, thanks to the same Time.deltaTime trick from section 7.

Unity also ships a newer, more flexible "Input System" package, better for rebindable controls and multiple players, but the classic Input class shown here needs no extra setup and is enough to build and understand everything in this book.

12. Coroutines: Doing Things Over Time

Everything so far runs top to bottom inside a single frame. Sometimes you want code that plays out over several seconds — a flashing effect, a delay before an enemy attacks, a countdown — without freezing the rest of the game while it waits. That is what a coroutine is for: a method that can pause partway through and pick up again later on its own, while everything else keeps running normally.

using UnityEngine;
using System.Collections;

public class Blinker : MonoBehaviour
{
    public Renderer targetRenderer;

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

    IEnumerator Blink()
    {
        while (true)
        {
            targetRenderer.enabled = false;
            yield return new WaitForSeconds(0.2f);
            targetRenderer.enabled = true;
            yield return new WaitForSeconds(0.2f);
        }
    }
}

What happens: Start calls StartCoroutine(Blink()) once, which begins running Blink. The renderer turns off, then yield return new WaitForSeconds(0.2f) pauses this method only for 0.2 real seconds — the rest of the game, including this same object's own Update if it had one, keeps running normally the whole time. After 0.2 seconds, execution resumes on the very next line, turns the renderer back on, waits again, and loops forever — a steady blink, with no manual timer variable anywhere.

A method returning IEnumerator is what makes a coroutine possible: instead of running start-to-finish in one go like a normal method, it is built so Unity can pause it at each yield return and resume it later at exactly that point. You never call Blink() directly like a normal method — you always start it with StartCoroutine, which hands control of the pausing and resuming over to Unity.

Common things to yield return:

Tip A coroutine started on a GameObject stops automatically if that GameObject is destroyed, or if the component running it is disabled. You can also stop one manually with StopCoroutine.

13. ScriptableObjects: Shared Data Assets

ScriptableObject is another base class Unity provides, similar in spirit to MonoBehaviour but built for data instead of behavior. A ScriptableObject is not attached to a GameObject in a scene — it lives as its own asset file in the Project window, the same way a texture or an audio clip does.

using UnityEngine;

[CreateAssetMenu(fileName = "NewWeaponData", menuName = "Data/Weapon Data")]
public class WeaponData : ScriptableObject
{
    public string weaponName = "Sword";
    public int damage = 10;
    public float attacksPerSecond = 1.5f;
}

What happens: [CreateAssetMenu(...)] adds an entry to the right-click "Create" menu in the Project window. Clicking it creates an actual .asset file, say Sword.asset, that a designer can open and edit in the Inspector exactly like any other object, filling in weaponName, damage, and attacksPerSecond with no code changes needed.

using UnityEngine;

public class Weapon : MonoBehaviour
{
    [SerializeField] private WeaponData data;

    public void Attack()
    {
        Debug.Log(data.weaponName + " hits for " + data.damage);
    }
}

What happens: because data is a [SerializeField] field, the Inspector shows a slot where you drag in the Sword.asset file created above. Calling Attack() prints "Sword hits for 10" using whatever values are currently saved on that asset.

The payoff shows up once many objects should share the same data. Imagine fifty sword-wielding enemies in a scene. Putting damage and weaponName directly as fields on each enemy's own MonoBehaviour means fifty separate copies of the same numbers — change the sword's damage, and you must remember to update all fifty by hand, or they quietly drift out of sync. Point all fifty enemies' data field at the same Sword.asset instead, and there is exactly one copy of the numbers: change it once, and every enemy that references it picks up the new value immediately.

Glossary

Exercises

Exercise 1 Write a script called OrderLogger that logs "Awake", "OnEnable", and "Start" (each exactly once) using Debug.Log, plus logs "First Update" the very first time Update runs, and never again after that. Attach it to a GameObject that starts active in the scene. Before running it, write down the order you expect the four log lines to appear in the Console.
Show answer
using UnityEngine;

public class OrderLogger : MonoBehaviour
{
    private bool hasLoggedFirstUpdate = false;

    void Awake()
    {
        Debug.Log("Awake");
    }

    void OnEnable()
    {
        Debug.Log("OnEnable");
    }

    void Start()
    {
        Debug.Log("Start");
    }

    void Update()
    {
        if (!hasLoggedFirstUpdate)
        {
            Debug.Log("First Update");
            hasLoggedFirstUpdate = true;
        }
    }
}

Console output, in order:

Awake
OnEnable
Start
First Update

Awake and OnEnable fire before the first frame even begins. Start also fires before the first frame's Update, since Unity guarantees Start runs before any Update call on the same object. Only then does the per-frame loop begin, so "First Update" is always last.

Exercise 2 The script below moves an object forward and has two bugs: it calls GetComponent every frame instead of caching it, and it moves the object the same fixed distance every frame instead of scaling by Time.deltaTime, so it moves at different speeds on different machines. Rewrite it to fix both problems.
using UnityEngine;

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

    void Update()
    {
        GetComponent<Rigidbody>().MovePosition(
            GetComponent<Rigidbody>().position + Vector3.forward * speed);
    }
}
Show answer
using UnityEngine;

public class FixedMover : MonoBehaviour
{
    public float speed = 5f;
    private Rigidbody rb; // cached once, instead of searched every frame

    void Awake()
    {
        rb = GetComponent<Rigidbody>();
    }

    void Update()
    {
        rb.MovePosition(rb.position + Vector3.forward * speed * Time.deltaTime);
    }
}

Two separate fixes: caching rb in Awake means GetComponent runs once instead of twice every single frame. Multiplying by Time.deltaTime turns speed from "units moved per call" into "units moved per second," so the object now covers the same real-world distance per second no matter the frame rate — the fix from section 7 applied to a new example.

Exercise 3 Write a coroutine called SpawnLoop that, while a public bool field isSpawning is true, instantiates a public enemyPrefab at a public spawnPoint Transform's position every 2 seconds. Start the coroutine once in Start. The loop should stop spawning, without throwing an error, whenever isSpawning becomes false, and it should never start a second coroutine running at the same time.
Show answer
using UnityEngine;
using System.Collections;

public class EnemySpawner : MonoBehaviour
{
    public GameObject enemyPrefab;
    public Transform spawnPoint;
    public bool isSpawning = true;

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

    IEnumerator SpawnLoop()
    {
        while (isSpawning)
        {
            Instantiate(enemyPrefab, spawnPoint.position, spawnPoint.rotation);
            yield return new WaitForSeconds(2f);
        }
    }
}

The while (isSpawning) check runs every time the coroutine wakes back up after a WaitForSeconds, not just once at the start — so setting isSpawning = false from anywhere else in the code (another script, a UI button) quietly ends the loop the next time it wakes up, with no extra code needed to cancel anything. Only one coroutine is ever running because StartCoroutine(SpawnLoop()) is called exactly once, in Start.

← Back to all chapters