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

Scripting in Unity: Lifecycle, Serialization, Coroutines & Awaitable

Learn how Unity runs your code: the MonoBehaviour lifecycle and the frame loop, time and frame-rate independence, finding and caching components, Unity's special null, what serialization saves, coroutines and async code with Awaitable, and how static state behaves across Play sessions. Every script compiles against Unity 6.3, and the guided build brings Lighthouse Island to life at night.

Study time
12–16 hours
Reading time
114 min read · 11,370 words
Prerequisites
T02 · The Unity Editor: Scenes, GameObjects, Components & Prefabs, T04 · C# Beyond Basics: OOP, Generics, Collections, LINQ, Delegates & Memory
Courses merged
9 course files
Your progress
—
After this chapter you can…
  • Explain when Unity calls Awake, OnEnable, Start, FixedUpdate, Update, LateUpdate, OnDisable and OnDestroy, and put each kind of code in the right one
  • Write movement and timers that run at the same speed at any frame rate, and know what Time.timeScale does to them
  • Connect scripts through Inspector references, GetComponent and TryGetComponent, and know which lookups are too slow for every frame
  • Explain Unity's special null and avoid the bugs it causes with ?. and ??
  • Predict exactly which fields Unity saves and shows in the Inspector, and use serialization attributes to make components easy to use
  • Do work over time with coroutines and with async/await and Awaitable, including stopping and cancelling it safely
  • Write static state and events that behave correctly whether or not domain reload is enabled
  • Use assembly definitions and Unity's debugging tools to keep a growing project fast and understandable

A console program runs from the first line of Main to the last. A Unity script doesn't: nothing in it runs until Unity decides to call it. This chapter is about that relationship, which T00 · How to Learn Unity with This Handbook warned nobody should skip: when Unity calls your methods, what it saves of your data, what "null" means for a destroyed object, and how to do things that take longer than one frame.

Every script in this chapter has been compiled against the Unity 6.3 editor's assemblies with no errors or warnings, and is copied into the handbook's sandbox project under Assets/Handbook/T05. The chapter assumes the C# from T03 · C# Fundamentals: Types, Logic, Methods & Classes and T04 · C# Beyond Basics: OOP, Generics, Collections, LINQ, Delegates & Memory, especially events and iterators, and the Editor skills from T02 · The Unity Editor: Scenes, GameObjects, Components & Prefabs.

The big picture#

A game is a loop. Many times a second, Unity reads input, moves things, runs physics, plays animations and draws a frame. Your code doesn't own that loop. Instead, you write components with methods that have special names, such as Start and Update, and Unity calls them at the right moment in each frame. This is sometimes called the Hollywood principle: "don't call us, we'll call you".

Three consequences shape everything in this chapter:

  • Order matters. If one script reads a value in its Start that another sets in its Start, the result depends on which runs first. Unity gives you rules (Awake before Start, Update before LateUpdate) and you design around them.
  • A frame is short. At 60 frames per second you have about 16 milliseconds for everything. A method that takes 100 ms freezes the game for six frames. Anything that happens over time, such as a fade, a countdown or a door swinging, must be spread across frames, which is what Update, coroutines and Awaitable are for.
  • Unity owns the objects. Components are created and destroyed by Unity, their fields are saved and loaded by Unity's serializer, and a destroyed object becomes a strange half-null. Code that ignores this works in simple tests and fails in real scenes.

Anatomy of a script#

When you choose Assets → Create → Scripting → MonoBehaviour Script and name it Lamp, Unity writes a file like this (comments trimmed):

Lamp.cs, as Unity creates itC#
using UnityEngine;

public class Lamp : MonoBehaviour
{
    // Start is called once before the first execution of Update after the MonoBehaviour is created
    void Start()
    {
    }

    // Update is called once per frame
    void Update()
    {
    }
}
  • The file name and class name must match (Lamp.cs holds class Lamp). Unity uses the file to find the class when you add it to a GameObject; if they differ, Unity can't add the component and says the script class can't be found. Renaming a class means renaming the file too, ideally from inside Unity or your IDE so the .meta file keeps the link to every object that uses it.
  • Namespaces are allowed and recommended, as the handbook's scripts do (namespace Handbook.T05). They keep your Health from clashing with someone else's.
  • Deriving from MonoBehaviour makes the class a component, with everything T04 · C# Beyond Basics: OOP, Generics, Collections, LINQ, Delegates & Memory showed it inherits: transform, gameObject, enabled, GetComponent and more.
  • No constructor. Unity creates components itself, often at times you don't expect (when loading a scene, when the Editor reloads scripts), and the Manual warns that defining a constructor for a MonoBehaviour can cause major problems. Put setup code in Awake or Start. For the same reason you can't create a component with new; use AddComponent<Lamp>() on a GameObject.

Event functions are found by name#

Start and Update aren't overrides of anything; T04 · C# Beyond Basics: OOP, Generics, Collections, LINQ, Delegates & Memory pointed out that they aren't virtual. Unity looks for methods with these exact names and signatures on your class and, if it finds them, calls them. Unity's documentation calls them event functions (the API reference lists them under Messages). Three things follow:

  • They work whether they're private or public. Leaving them private (no modifier) is the convention: nothing else should call your Update.
  • A typo means the method is never called, with no error: void update() or void Updtae() is just an ordinary method nobody calls. Your IDE, with the Unity support from T01 · Setting Up: Unity 6, Hub, IDE, Packages & Version Control installed, colours or marks real event functions, which catches this.
  • An empty event function still costs a little: Unity calls it every frame. Delete empty Update methods instead of leaving them.

The enabled checkbox#

The checkbox next to a script's name in the Inspector is Behaviour.enabled. Unticking it stops Unity calling Update, LateUpdate and FixedUpdate on that component, and calls OnDisable; ticking it again calls OnEnable. It doesn't stop everything: other scripts can still call its public methods, its event handlers still run, physics messages such as OnCollisionEnter still arrive, and (as you'll see) coroutines keep going. The checkbox only appears if the script has event functions that being disabled would affect, such as Start or Update; a script with none of them has no checkbox. Deactivating the whole GameObject (gameObject.SetActive(false)) is stronger: every component on it and its children stops.

The lifecycle of a component#

Every component goes through the same stages: it's created and initialised, it's updated every frame while active, and eventually it's disabled and destroyed. Unity calls a different event function at each step. The diagram shows the main ones in order; Unity's Manual page Event function execution order has the full chart, including rendering and animation.

Initialisation (once) Awake OnEnable Start Start: just before this component's first Update Every frame Physics step: 0, 1 or more times FixedUpdate physics simulation OnTrigger… / OnCollision… yield WaitForFixedUpdate input events Game logic: once a frame Update yield null, WaitForSeconds animation update LateUpdate (following cameras go here) Rendering cameras draw the frame; gizmos draw in the Scene view yield WaitForEndOfFrame Teardown (once) OnApplicationQuit OnDisable OnDestroy OnApplicationQuit runs only when quitting or leaving Play mode. OnDisable also runs whenever the component or its object is turned off.
The main event functions in the order Unity calls them. Greyed yield lines show where waiting coroutines resume. Within one frame, the physics step can run several times (or not at all) before Update runs once.

The best way to learn the order is to watch it. This probe logs each event function with the frame number:

LifecycleProbe.csUnity sandboxC#
using UnityEngine;

namespace Handbook.T05
{
    // Logs Unity's event functions as they happen, with the frame number.
    // Put it on two or three objects, disable some, and read the Console.
    public class LifecycleProbe : MonoBehaviour
    {
        [SerializeField] bool logEveryFrame;   // off by default: per-frame logs flood the Console

        bool loggedFixedUpdate;
        bool loggedUpdate;
        bool loggedLateUpdate;

        void Log(string message) => Debug.Log($"[frame {Time.frameCount}] {name}: {message}", this);

        void Awake() => Log("Awake");
        void OnEnable() => Log("OnEnable");
        void Start() => Log("Start");

        void FixedUpdate()
        {
            if (logEveryFrame || !loggedFixedUpdate) Log("FixedUpdate");
            loggedFixedUpdate = true;
        }

        void Update()
        {
            if (logEveryFrame || !loggedUpdate) Log("Update");
            loggedUpdate = true;
        }

        void LateUpdate()
        {
            if (logEveryFrame || !loggedLateUpdate) Log("LateUpdate");
            loggedLateUpdate = true;
        }

        void OnDisable() => Log("OnDisable");
        void OnDestroy() => Log("OnDestroy");
        void OnApplicationQuit() => Log("OnApplicationQuit");
    }
}

Try this: create three cubes named A, B and C and add LifecycleProbe to each. Untick the component on B (the checkbox next to its name), and untick the whole GameObject C (the checkbox next to its name at the top of the Inspector). Press Play, wait a moment, then stop. A logs the full sequence. B logs only Awake, and C logs nothing at all. During Play, tick B's component: it logs OnEnable, then Start, then starts updating. Tick C's GameObject: it logs Awake, OnEnable and Start, late but in order. When you stop, the active probes log OnApplicationQuit, OnDisable and OnDestroy. Passing this as the second argument to Debug.Log means clicking a message in the Console highlights the object that logged it.

Awake, OnEnable and Start#

These three run once at the beginning, but at different moments and under different conditions. The rules below come from the 6.3 Scripting API:

EventWhen Unity calls itEven if the component is disabled?
AwakeOnce, when the object first becomes active: on scene load, on first activation, or inside InstantiateYes, as long as the GameObject is active
OnEnableEach time the component becomes enabled and its GameObject active; on load, right after AwakeNo
StartOnce, before the component's first Update; only after every object in the scene has had AwakeNo: it waits until the component is first enabled

The guarantee in the last row is the one to build on: no Start runs until every Awake in the scene has run. That gives you a simple, reliable rule:

  • In Awake, set up yourself. Cache your own components, initialise your own fields, build your own lookup tables. Don't ask other objects for their data yet; they may not be set up.
  • In OnEnable, connect. Subscribe to events, register with managers. Pair every subscription with an unsubscription in OnDisable.
  • In Start, talk to others. Every other object has finished its Awake, so it's safe to read their state.

Update, LateUpdate and FixedUpdate#

  • Update runs once per frame, for every enabled component on an active GameObject. It's where most game logic goes: reading input, moving things that aren't physics-driven, ticking timers.
  • LateUpdate runs once per frame after every Update and after animation. Use it for things that must react to where everything ended up this frame. The classic case is a camera that follows a target: if the camera moved in Update, it might move before the target does and lag one frame behind.
  • FixedUpdate runs at a fixed rate, by default every 0.02 s (50 times a second), independent of the frame rate. That means zero, one or several times per frame. It's for physics: applying forces to a Rigidbody belongs here, as T08 · 3D Physics: Rigidbodies, Colliders, Raycasts & Character Controllers explains. Don't read one-off input in FixedUpdate; a key press that lasts a single frame can fall in a frame with no fixed step and be missed.

OnDisable, OnDestroy and quitting#

OnDisable runs whenever the component stops being active: when it's disabled, when its GameObject is deactivated, when it's destroyed, when the scene unloads, and when scripts are reloaded. That makes it the reliable place to undo what OnEnable did. OnDestroy runs when the component or its GameObject is destroyed, when the scene unloads, and when you quit or leave Play mode, but only on objects that were active at some point. OnApplicationQuit runs on all components of active GameObjects when the application quits or Play mode ends, before the others.

Reset and OnValidate: editor helpers#

Two event functions run in the Editor rather than during play. Reset runs when you first add the component, or choose Reset from its ⋮ menu; use it to fill in good defaults that field initialisers can't, such as finding a child Light to drag into a slot for you. It never runs in a build. OnValidate runs when a value changes in the Inspector, and at other times such as loading and entering Play mode; use it to keep values consistent (clamp a range, strip invalid characters). Keep it light and don't use it to create or destroy objects. The BeaconLight script later uses both.

Objects created during play#

When you Instantiate an active object, its Awake and OnEnable run inside the Instantiate call, before it returns. Its Start runs later: if you instantiate from Update, Unity calls Start on the new object before the end of the current frame, before that object's first Update. So in the line after Instantiate, the new object's Awake has run but its Start hasn't. If you need to hand it data (a target, a speed), set it right after Instantiate, and read it in the new object's Start.

Start can be a coroutine or async#

Start may return IEnumerator, which makes it a coroutine that can wait, or be declared async Awaitable Start(), which lets it use await. Both appear later in this chapter; the guided build's ship spawner uses the second form.

Order between scripts#

The lifecycle fixes the order of event functions within a component, but not between components: if two different scripts both have Update, which runs first isn't defined, and the Manual warns not to rely on whatever order you observe. Usually you shouldn't need to: use Awake/Start and Update/LateUpdate to separate stages. When you really need one script to run before another (an input reader before the movement that uses it, say), you can set a script execution order:

  • In code, with the [DefaultExecutionOrder(-100)] attribute on the class. Lower numbers run earlier; the default is 0.
  • In Project Settings → Script Execution Order. A value set there wins over the attribute.

The order applies to all instances of a script: every component with a lower value runs before any with a higher one. It doesn't affect OnDisable and OnDestroy, and it never orders two instances of the same script against each other. Use it sparingly; a project with many custom orders becomes hard to reason about.

Time and frame-rate independence#

Frames aren't evenly spaced. One machine runs your game at 144 frames per second, another at 30, and the same machine varies from frame to frame. If a script moves an object 0.1 m every Update, it moves 14.4 m a second on the first machine and 3 m a second on the second. The fix is to think in units per second and multiply by how long the last frame took:

Moving at a steady speedC#
[SerializeField] float speed = 3f;   // metres per second

void Update()
{
    // Wrong: 0.1 m per frame, so faster on faster machines.
    // transform.position += Vector3.forward * 0.1f;

    // Right: metres per second × seconds this frame = metres this frame.
    transform.position += Vector3.forward * (speed * Time.deltaTime);
}

Time.deltaTime is the time in seconds since the last frame: about 0.0167 at 60 fps, 0.033 at 30 fps. Multiplying a per-second rate by it gives the right amount for this frame, so after one second the object has moved speed metres whatever the frame rate. The same applies to rotation (degrees per second), timers (timer -= Time.deltaTime), fading and anything else that changes over time. Inside FixedUpdate, Time.deltaTime returns the fixed step, so the same code works there too.

Frames (Update) 0.023 0.015 0.038 0.040 (slow) 0.017 0.020 0.043 Fixed steps (FixedUpdate) 0.02 0.02 0.02 slow frame: two fixed steps short frame: none Frames vary in length; fixed steps don't. Unity runs the fixed steps that are due, then Update once.
Time.deltaTime is the length of the last frame and changes all the time; Time.fixedDeltaTime is the physics step, 0.02 s by default. A long frame runs several fixed steps to catch up, and a short one may run none.

The Time values you'll use#

PropertyWhat it isTypical use
Time.deltaTimeSeconds since last frame, scaled by timeScaleMovement, timers, anything per second
Time.unscaledDeltaTimeThe same, ignoring timeScalePause menus, UI animation during slow motion
Time.fixedDeltaTimeThe fixed step (Project Settings → Time)Rarely read directly; physics uses it
Time.timeScaled seconds since the game startedCooldowns: if (Time.time >= nextShot)
Time.timeScaleHow fast game time passes (1 = normal)Pause (0), slow motion (0.3)
Time.frameCountFrames since the game startedLogging, doing work every Nth frame

Time.timeScale doesn't change how often Update runs; it changes the deltaTime reported to it. At 0.5, deltaTime is halved and everything written with it moves at half speed. At 0, deltaTime is 0, so frame-rate independent movement stops, which is the simplest way to pause a game (and FixedUpdate stops being called too). Anything that must keep running while paused, like the pause menu's own animation, uses unscaledDeltaTime. If your game freezes after a pause menu, check that something sets timeScale back to 1.

Connecting scripts: references and components#

Almost every script needs another object: the health bar needs the health, the turret needs its target, the door needs its hinge. T00 · How to Learn Unity with This Handbook named the three usual causes of a NullReferenceException in Unity: a field not assigned in the Inspector, a GetComponent that found nothing, and using something before the method that sets it has run. This section is about avoiding all three. In order of preference, there are four ways to get a reference.

1. Drag it in: serialized references#

A [SerializeField] field of a component type, such as [SerializeField] Light beacon;, shows up as a slot in the Inspector, and you drag an object into it. This is the best default. It costs nothing at runtime, it's visible (anyone can see what's connected to what), and it doesn't break when you rename or move objects in the Hierarchy. Its only weakness is that someone has to remember to fill the slot, so check for null where a missing reference is possible, log a clear warning, and use Reset to fill obvious slots automatically.

2. On the same object: GetComponent#

GetComponent<T>() returns the first component of type T on the same GameObject, or null if there isn't one. Its relatives search further:

MethodSearches
GetComponent<T>()This GameObject
TryGetComponent(out T c)This GameObject; returns true or false instead of null
GetComponentInChildren<T>()This object, then its children, depth first (active ones unless you pass true)
GetComponentInParent<T>()This object, then its parent, grandparent…
GetComponents<T>(list)All matching components, filled into a list you pass in

These are fast but not free, so call them once and keep the result, usually in Awake: a field like Health health; set with health = GetComponent<Health>();. Calling GetComponent in every Update wastes time for nothing, because the answer doesn't change. TryGetComponent is the best choice when the component may legitimately be missing: TryGetComponent(out Health h) returns false if there's no Health, and gives you the component in h if there is.

When a script can't work without another component, say so with [RequireComponent(typeof(Health))] on the class. Unity then adds a Health automatically when you add your script, and won't let you remove it while your script is there, so GetComponent<Health>() can't come back null in that script.

3. Search the scene: the Find methods#

FindAnyObjectByType<T>() returns some active object of type T anywhere in the loaded scenes; FindFirstObjectByType<T>() is similar but slower, because it must work out which one is "first"; FindObjectsByType<T>(…) returns all of them as a new array (pass FindObjectsSortMode.None unless you need them sorted). GameObject.FindWithTag("Player") finds an active object by tag. All of these search the whole scene, so they're slow and get slower as the scene grows. Use them once, in Start or Awake, if at all; never in Update. They also hide dependencies: nothing in the Inspector shows that your script needs a Player. Prefer serialized references, or objects that register themselves (as Exercise 7 of T04 · C# Beyond Basics: OOP, Generics, Collections, LINQ, Delegates & Memory did with a static list).

4. Ask the thing you touched#

The fourth way comes from the game itself: a collision, a trigger or a raycast hands you the other object, and you look up the component you need on it with TryGetComponent. That's how damage, pickups and interaction usually work, and T08 · 3D Physics: Rigidbodies, Colliders, Raycasts & Character Controllers covers it with physics.

A component others can use#

Here's a Health component written the way this chapter recommends: settings serialized with helpful attributes, state private, a small public API, and events so other scripts can react. A HealthBar on the same object uses it.

Health.csUnity sandboxC#
using System;
using UnityEngine;
using UnityEngine.Serialization;

namespace Handbook.T05
{
    // Health with a safe public API and events. Other scripts react to it; it knows none of them.
    [DisallowMultipleComponent]
    public class Health : MonoBehaviour
    {
        [Header("Settings")]
        [Tooltip("Health when the object spawns.")]
        [FormerlySerializedAs("hitPoints")]          // this field used to be called hitPoints
        [SerializeField, Min(1)] int maxHealth = 100;

        [Tooltip("Seconds of protection after each hit.")]
        [SerializeField, Range(0f, 2f)] float graceTime = 0.25f;

        int current;          // private and not serialized: runtime state only
        float graceUntil;

        public int Current => current;
        public int Max => maxHealth;
        public bool IsDead => current <= 0;

        public event Action<int, int> Changed;   // (current, max)
        public event Action Died;

        void Awake() => current = maxHealth;

        public void TakeDamage(int amount)
        {
            if (amount <= 0 || IsDead || Time.time < graceUntil) return;

            current = Mathf.Max(0, current - amount);
            graceUntil = Time.time + graceTime;
            Changed?.Invoke(current, maxHealth);
            if (IsDead) Died?.Invoke();
        }

        public void Heal(int amount)
        {
            if (amount <= 0 || IsDead) return;

            current = Mathf.Min(maxHealth, current + amount);
            Changed?.Invoke(current, maxHealth);
        }

        [ContextMenu("Take 10 Damage")]   // test from the component's ⋮ menu in Play mode
        void TakeTestDamage() => TakeDamage(10);
    }
}
HealthBar.csUnity sandboxC#
using UnityEngine;

namespace Handbook.T05
{
    // Shows a Health value by scaling a bar along X. Needs a Health on the same object.
    [RequireComponent(typeof(Health))]
    public class HealthBar : MonoBehaviour
    {
        [SerializeField] Transform bar;   // a thin child object; it shrinks towards its pivot

        Health health;

        // Set up yourself: cache the component once.
        void Awake() => health = GetComponent<Health>();

        // Connect: subscribe and unsubscribe in matching pairs.
        void OnEnable() => health.Changed += Show;
        void OnDisable() => health.Changed -= Show;

        // Talk to others: every Awake has run, so Health has set its value.
        void Start() => Show(health.Current, health.Max);

        void Show(int current, int max)
        {
            if (bar == null) return;
            Vector3 scale = bar.localScale;
            scale.x = (float)current / max;   // copy, change, assign: Vector3 is a struct
            bar.localScale = scale;
        }
    }
}

Try it: put Health and HealthBar on a cube, give it a child cube named Bar (position 0, 0.8, 0, scale 1, 0.1, 0.1) and drag Bar into the slot. It shrinks towards its centre; to make it shrink from one end, put it under an empty object placed at that end and drag the empty in instead. Press Play, open the Health component's ⋮ menu and choose Take 10 Damage a few times. The bar shrinks. Notice the three stages: HealthBar caches Health in Awake, subscribes in OnEnable, and reads the starting value in Start. If it read the value in OnEnable, it might run before Health.Awake and show 0.

The (float)current / max cast matters: both are ints, and without it 90 / 100 is integer division, which gives 0, as T03 · C# Fundamentals: Types, Logic, Methods & Classes warned. The bar would vanish on the first hit.

Unity's null#

T03 · C# Fundamentals: Types, Logic, Methods & Classes warned that ?. and ?? are unsafe on Unity objects. Here's why. A component, GameObject or asset is really two objects: a C# object that your variables point at, and a native C++ object inside the engine that holds the real data. When you Destroy something, Unity destroys the native part (just after the current Update loop), but it can't destroy the C# object: the garbage collector owns that, and it lives on while any variable still points at it.

Alive target C# object native C++ object target == null: false everything works After Destroy(target) target C# object gone target == null: true target?.X: calls X, throws target is null: false Unity overrides == and != for its objects; ?. , ?? and "is null" can't be overridden and see only the C# side.
A destroyed Unity object is "null" to == but not to ?., ?? or is null. The same happens with unassigned fields in the Editor, which hold a placeholder object for better error messages.

To make destroyed objects behave sensibly, UnityEngine.Object overrides == and !=: comparing a destroyed object with null returns true, even though the C# reference isn't null. Unity developers call this "fake null". C# doesn't let a class override ?., ?? or is null, so those only check whether the C# reference is null, and they disagree with ==. Unassigned serialized fields behave the same way in the Editor: Unity fills them with a placeholder object that compares equal to null but can produce a more helpful error message.

  • Use == null and != null on anything derived from UnityEngine.Object: components, GameObjects, materials, ScriptableObjects. Or use the shorthand if (target), which Unity also supports and which means "exists and isn't destroyed".
  • Don't use ?., ??, ??= or is null on them. target?.TakeDamage(5) on a destroyed target calls the method anyway, and it fails as soon as it touches the engine.
  • On your own plain C# classes and on C# delegates and events, ?. is fine; there's no native side.
  • Interface variables (IDamageable target) use ordinary C# comparison, because the compiler doesn't know the object is a Unity object. A destroyed component held through an interface is not null to ==. Keep a typed reference too, or convert first, var o = target as UnityEngine.Object;, and test o != null.

The four "null" exceptions#

ExceptionUsual causeFix
NullReferenceExceptionA plain C# reference is nullAssign it, or check before using it
UnassignedReferenceExceptionA serialized field was never filled in the InspectorDrag the object into the slot
MissingReferenceExceptionThe object was destroyed but your code still uses itCheck != null; unsubscribe on disable
MissingComponentExceptionUsing the result of a GetComponent that found nothingRequireComponent or TryGetComponent

The last three are the Editor's clearer versions, made possible by the placeholder objects; in a build, an unassigned field or a missing component usually shows up as a plain NullReferenceException. Double-clicking the error in the Console opens the line that threw. The first line of the stack trace below the message is where it happened; read down to find which of your calls led there.

Creating, enabling and destroying objects#

Instantiate#

Instantiate copies an object, usually a prefab, into the scene and returns the copy. Its most useful forms:

Instantiate overloadsC#
// Copy at the prefab's own position and rotation
GameObject a = Instantiate(prefab);

// Copy at a position and rotation
GameObject b = Instantiate(prefab, spawnPoint.position, spawnPoint.rotation);

// Copy as a child of a parent (keeps the prefab's local position)
GameObject c = Instantiate(prefab, parent);

// If the field's type is a component, you get that component back directly
[SerializeField] Ship shipPrefab;
Ship ship = Instantiate(shipPrefab, spawnPoint.position, spawnPoint.rotation);
ship.name = "Ship 1";

The last form is worth adopting everywhere: make prefab fields the type of their main component rather than GameObject. The Inspector then accepts only prefabs that have that component, and you get the component back without a GetComponent call. The copy's Awake and OnEnable have run by the time Instantiate returns, so you can configure it on the next line.

SetActive and enabled#

gameObject.SetActive(false) turns off a whole GameObject: every component on it and on its children stops, it's not drawn and it has no collisions. component.enabled = false turns off one component only. activeSelf is the object's own checkbox; activeInHierarchy is whether it's really active, which is false if any parent is inactive. Deactivating is cheap and reversible, which is why pools deactivate objects instead of destroying them.

Destroy#

Destroy(gameObject) removes the whole object; Destroy(this) removes only this component; Destroy(gameObject, 2f) does it after two seconds. Destruction is delayed until after the current Update loop (it always happens before rendering), so the rest of the current method still runs and the object still answers == null with false until then. Don't use DestroyImmediate in game code; it exists for editor scripts. When a scene unloads, its objects are destroyed; DontDestroyOnLoad(gameObject) keeps a root object alive across scene loads, which T10 · Your First Complete Game Loop: States, Scenes, UI, Audio & Builds uses for objects that manage the whole game.

Serialization: what Unity saves#

Serialization is turning objects into data that can be stored, and back again. Unity does it constantly: when you save a scene or prefab, it writes each component's fields to the file; when you open it, it creates the components and fills the fields back in; when you edit a value in the Inspector, you're editing that serialized data; and when you change a script, Unity serializes everything, reloads your code and deserializes it again. Whatever isn't serialized is lost on every one of those trips. So the question "will this field be saved and shown?" has a precise answer, and it's worth knowing by heart.

Scene or prefab file on disk (YAML) Component maxHealth = 120 saved graceTime = 0.5 saved current = 0 not saved Inspector shows, edits load save same data Not-saved fields start from their code defaults on every load, Play and script reload.
Only serialized fields travel between the file, the component and the Inspector. Everything else is rebuilt from code each time.

The rules#

From the 6.3 Manual, a field is serialized if all of these hold:

  • It's public, or has the [SerializeField] attribute.
  • It isn't static, const or readonly.
  • Its type is serializable: a primitive (int, float, bool, string…), an enum, a Unity built-in value type (Vector3, Color, Rect, AnimationCurve…), a reference to a UnityEngine.Object (components, GameObjects, assets), a custom class or struct marked [Serializable], or an array or List<T> of any of those.

Not serialized: dictionaries, hash sets, queues and stacks; multidimensional and jagged arrays; nested collections such as a list of lists (wrap the inner list in a [Serializable] class instead); interfaces (unless you use [SerializeReference]); properties; and anything static. Unity serializes fields, not properties. An auto-property can still be serialized through its hidden backing field by putting [field: SerializeField] on the property, which T03 · C# Fundamentals: Types, Logic, Methods & Classes mentioned.

Field initialisers versus saved values#

The = 100 in int maxHealth = 100; is only a default for components added from now on. Once a component exists in a scene or prefab, its saved value wins: change the initialiser to 150 and every existing Health still has 100. This surprises everyone once. To change existing components, change the value in the prefab (so every instance gets it) or use the component's ⋮ → Reset, which reapplies initialisers and runs Reset().

Renaming a serialized field has the opposite problem: Unity matches saved data to fields by name, so after renaming hitPoints to maxHealth every saved value is lost. [FormerlySerializedAs("hitPoints")] (from UnityEngine.Serialization) tells Unity to read the old name too, as Health does. Keep it until every scene and prefab has been resaved.

Your own data classes#

A plain class or struct marked [Serializable] is saved inline inside the component that holds it, and the Inspector shows it as a foldout. That's how you build structured data: a loot table entry, a wave definition, a dialogue line. Since dictionaries aren't serialized, the standard pattern is a serialized list that becomes a dictionary at runtime:

LootTable.csUnity sandboxC#
using System;
using System.Collections.Generic;
using UnityEngine;

namespace Handbook.T05
{
    // Unity can't save a Dictionary, so the data is a list of [Serializable] entries
    // that becomes a dictionary when the object wakes up.
    public class LootTable : MonoBehaviour
    {
        [Serializable]
        public class Entry
        {
            public string itemId = "coin";
            [Min(0)] public int weight = 1;
        }

        [SerializeField] List<Entry> entries = new List<Entry>();

        readonly Dictionary<string, int> weights = new Dictionary<string, int>();
        int totalWeight;

        void Awake()
        {
            foreach (Entry entry in entries)
            {
                if (string.IsNullOrEmpty(entry.itemId) || entry.weight <= 0) continue;
                weights.TryGetValue(entry.itemId, out int existing);
                weights[entry.itemId] = existing + entry.weight;   // duplicates add up
                totalWeight += entry.weight;
            }
        }

        public int WeightOf(string itemId) => weights.TryGetValue(itemId, out int w) ? w : 0;

        // Picks an item: an entry with weight 3 is three times as likely as one with weight 1.
        public string Roll()
        {
            if (totalWeight == 0) return null;
            int ticket = UnityEngine.Random.Range(0, totalWeight);   // 0 to totalWeight - 1
            foreach (KeyValuePair<string, int> pair in weights)
            {
                if (ticket < pair.Value) return pair.Key;
                ticket -= pair.Value;
            }
            return null;
        }

        [ContextMenu("Roll Ten Times")]
        void RollTen()
        {
            for (int i = 0; i < 10; i++)
            {
                Debug.Log(Roll(), this);
            }
        }
    }
}

Add it to any object, add a few entries in the Inspector (coin 10, gem 3, crown 1), press Play and use Roll Ten Times. Two details: UnityEngine.Random is written in full because System also has a Random class, and with both namespaces imported the short name is ambiguous (error CS0104); and Random.Range with ints excludes the maximum, so Range(0, totalWeight) returns 0 to totalWeight - 1.

Inline serialization has limits: a serialized field of a plain class can't be null (Unity fills it with a new object), two fields referring to the same instance become two separate copies, and a field of a base class type loses the derived class's data. When you need those, [SerializeReference] stores the object by reference, supports null, sharing and polymorphism, at some extra cost. You'll rarely need it until you build systems like ability lists with different ability types; T13 · ScriptableObjects & Data-Driven Architecture and T16 · Saving & Loading: Serialization, Save Architecture & Versioning come back to it.

Attributes that make components pleasant to use#

AttributeEffect
[SerializeField]Serialize and show a private field
[HideInInspector]Serialize but don't show
[System.NonSerialized]Don't serialize a public field (and don't show it)
[Header("Movement")]A bold heading above the field
[Tooltip("…")]Text shown when hovering over the field's name
[Range(0f, 1f)], [Min(0)]A slider, or a lower limit
[Space], [TextArea(2, 5)]Extra gap; a multi-line text box
[FormerlySerializedAs("old")]Keep saved data after renaming a field
[ContextMenu("Do it")]Adds a method to the component's ⋮ menu
[RequireComponent(typeof(T))]Adds T automatically and stops it being removed
[DisallowMultipleComponent]Only one of this component per GameObject
[AddComponentMenu("Game/Health")]Where it appears in the Add Component menu
[SelectionBase]Clicking a child in the Scene view selects this object

Attributes go in square brackets before the field or class, and several can share one pair of brackets: [SerializeField, Min(1)]. They cost nothing at runtime. A few minutes adding headers, tooltips and ranges turns a component that only its author can use into one anyone on the team can tune safely.

ScriptableObjects in one paragraph#

Everything above serializes data inside a component on a GameObject. A ScriptableObject is a class whose instances are assets in the Project window, saved as their own files, with no GameObject at all. You derive from ScriptableObject instead of MonoBehaviour, add [CreateAssetMenu], and create instances from the Create menu. They're ideal for data shared by many objects: one "Gull" asset with speed and health that every gull prefab points to, instead of copying the numbers into each. The same serialization rules apply. T13 · ScriptableObjects & Data-Driven Architecture is devoted to them.

Coroutines: work spread over frames#

Suppose a light should flash: on for a quarter of a second, off for a quarter, three times, then pause. Written as a normal method with a loop, all of it would happen inside one frame and you'd see nothing. You could do it in Update with timers and a state variable, but the logic gets scattered. A coroutine lets you write it as one readable sequence that pauses between steps.

A coroutine is an iterator method, exactly the kind from T04 · C# Beyond Basics: OOP, Generics, Collections, LINQ, Delegates & Memory: it returns IEnumerator and contains yield return. You start it with StartCoroutine, and Unity calls its MoveNext for you: it runs until the next yield return, then Unity looks at what you yielded to decide when to continue. yield return null means "continue next frame"; yield return new WaitForSeconds(0.5f) means "continue in half a second". Local variables keep their values across the pauses, because that's how iterators work.

A minimal coroutineC#
IEnumerator CountDown()
{
    for (int n = 3; n > 0; n--)
    {
        Debug.Log(n);
        yield return new WaitForSeconds(1f);   // pause here for a second
    }
    Debug.Log("Go!");
}

void Start()
{
    StartCoroutine(CountDown());   // calling CountDown() alone would do nothing
}

Coroutines are not threads. Everything runs on the main thread, one piece per frame; a coroutine that does heavy work between yields freezes the game just like a slow Update. What they give you is a clear way to write sequences in time.

A flashing beacon#

BeaconLight.csUnity sandboxC#
using System.Collections;
using System.Text;
using UnityEngine;

namespace Handbook.T05
{
    // Flashes a Light in a repeating pattern: '.' short flash, '-' long flash, ' ' pause.
    public class BeaconLight : MonoBehaviour
    {
        [SerializeField] Light beacon;
        [SerializeField] string pattern = ". . -   ";
        [SerializeField, Min(0.05f)] float unit = 0.25f;   // seconds per short flash

        Coroutine flashing;

        void OnEnable() => flashing = StartCoroutine(Flash());

        void OnDisable()
        {
            // Unity stops coroutines when the GameObject is deactivated, but not when
            // only this component is disabled, so stop it here to cover both.
            if (flashing != null) StopCoroutine(flashing);
            flashing = null;
            if (beacon != null) beacon.enabled = false;
        }

        IEnumerator Flash()
        {
            var shortWait = new WaitForSeconds(unit);        // made once, reused every flash
            var longWait = new WaitForSeconds(unit * 3f);

            while (true)
            {
                if (pattern.Length == 0)
                {
                    yield return shortWait;                  // an endless loop must always yield
                    continue;
                }
                foreach (char symbol in pattern)
                {
                    bool lit = symbol != ' ';
                    if (beacon != null) beacon.enabled = lit;
                    yield return symbol == '-' ? longWait : shortWait;
                    if (beacon != null) beacon.enabled = false;
                    yield return shortWait;                  // a gap between symbols
                }
            }
        }

        // Editor only: fill the slot automatically when the component is added.
        void Reset() => beacon = GetComponentInChildren<Light>();

        // Editor only: keep only the symbols the pattern understands.
        void OnValidate()
        {
            var clean = new StringBuilder();
            foreach (char c in pattern ?? "")
            {
                if (c == '.' || c == '-' || c == ' ') clean.Append(c);
            }
            pattern = clean.ToString();
        }
    }
}
  • StartCoroutine returns a Coroutine handle; keep it, and pass it to StopCoroutine to stop exactly that coroutine. StopAllCoroutines() stops every coroutine this component started.
  • Start in OnEnable, stop in OnDisable. The 6.3 Manual is precise about when Unity stops a coroutine for you: when its GameObject is deactivated, and when the component is destroyed. Disabling the component with its checkbox doesn't stop it. Pairing start and stop in OnEnable/OnDisable makes the checkbox behave the way people expect.
  • while (true) is fine in a coroutine as long as every path through the loop yields. A loop that can go round without yielding never gives control back, and Unity freezes. That's why the empty-pattern case yields too.
  • The WaitForSeconds objects are created once. new WaitForSeconds allocates, and the Manual recommends caching yield instructions you reuse.

What you can yield#

YieldResumes
yield return null;Next frame, after Update (doesn't allocate)
new WaitForSeconds(s)After s seconds of scaled game time (never, while timeScale is 0)
new WaitForSecondsRealtime(s)After s real seconds, ignoring timeScale
new WaitUntil(() => test)When the test becomes true (checked each frame)
new WaitWhile(() => test)When the test becomes false
new WaitForFixedUpdate()After the next physics step
new WaitForEndOfFrame()After rendering, at the end of the frame
StartCoroutine(Other())When the other coroutine finishes
An AsyncOperationWhen it completes, e.g. SceneManager.LoadSceneAsync
A (non-generic) AwaitableWhen it completes

yield break; ends a coroutine early. Coroutines can't return a value; if you need a result, set a field, call a method or use Awaitable<T> below. And a coroutine is owned by the component that started it: if you call other.StartCoroutine(...), it's tied to other's lifetime, not yours.

async, await and Awaitable#

C# has its own language feature for code that waits: async and await. A method marked async can contain await expressions; at each await the method pauses, returns control to its caller, and later continues from the same line with its variables intact, just like a coroutine at a yield. Unity 6 provides its own awaitable type, Awaitable, designed for games: it's pooled to avoid allocations and it knows about Unity's frame loop.

The countdown with async/awaitC#
async Awaitable CountDown()
{
    for (int n = 3; n > 0; n--)
    {
        Debug.Log(n);
        await Awaitable.WaitForSecondsAsync(1f);   // pause here for a second
    }
    Debug.Log("Go!");
}

async Awaitable Start()   // Start itself can be async
{
    await CountDown();      // wait for it to finish, then continue
    Debug.Log("Countdown finished");
}

It reads almost the same as the coroutine, with three real advantages: an async method can return a value (async Awaitable<int> RollAsync(), used as int roll = await RollAsync();); try/catch works across awaits, which it can't across a coroutine's yields; and you can wait for anything awaitable in one method: frames, seconds, asset loading, scene loading, web requests and your own operations.

What Awaitable offers#

MethodContinues
Awaitable.NextFrameAsync()On the next frame
Awaitable.WaitForSecondsAsync(s)After s seconds of game time
Awaitable.FixedUpdateAsync()At the next physics step
Awaitable.EndOfFrameAsync()At the end of the frame
Awaitable.BackgroundThreadAsync()On a background thread (for heavy, non-Unity work)
Awaitable.MainThreadAsync()Back on the main thread
Awaitable.FromAsyncOperation(op)When a Unity AsyncOperation completes

All of them except the thread switches take an optional CancellationToken, which is how you stop async code, and you can also await an AsyncOperation directly. Your own methods return Awaitable (nothing to return) or Awaitable<T> (returns a T).

Stopping async code: cancellation#

Here's the most important difference from coroutines. A coroutine dies with its component; an async method doesn't. If the object is destroyed while a method is waiting, the method still resumes when the wait ends, and the moment it touches transform or gameObject it throws a MissingReferenceException. You stop async code with a cancellation token:

  • destroyCancellationToken, a property of every MonoBehaviour, is cancelled when the component is destroyed (including when the scene unloads or Play mode ends). Pass it to every wait, as the last argument of WaitForSecondsAsync, NextFrameAsync and the others.
  • When a token is cancelled, the waiting call throws an OperationCanceledException. That's the mechanism that stops the method. Catch it at the top of your async method so it ends quietly.
  • For your own "stop this now" cases (restart a fade, cancel a dash), create a CancellationTokenSource, pass its Token, and call Cancel(). Exercise 5 does this.
  • Application.exitCancellationToken is cancelled when Play mode ends or the application quits. Unity doesn't stop background-thread work when you leave Play mode, so use it there.

The guided build's ShipSpawner shows the full pattern: an async Awaitable Start, every wait given destroyCancellationToken, and the OperationCanceledException caught once around the loop.

Rules for Awaitable#

  • Await each Awaitable once. Unity returns Awaitable objects to a pool after they've been awaited, so awaiting the same instance twice is undefined behaviour (an exception or a deadlock). If several pieces of code must wait for the same thing, use a .NET Task for that case.
  • It continues on the thread that called it. Started from the main thread (any event function), an Awaitable method resumes on the main thread, where Unity APIs are safe. Only BackgroundThreadAsync moves you off it, and most Unity APIs throw or crash when called from a background thread. Switch back with MainThreadAsync before touching the scene.
  • Avoid async void, except where something requires a void method. An exception in an async void method has no caller to go to. Return Awaitable, and either await it or catch exceptions inside it.
  • Don't use it for everything. The Manual notes that one awaitable loop per object, running every frame on thousands of objects, performs badly. For per-frame work on many objects, a plain Update (or a manager that updates them all) is still best.

Update, coroutine or Awaitable?#

UpdateCoroutineasync Awaitable
Best forContinuous per-frame behaviourSimple sequences in timeSequences, especially with results, loading or error handling
Stops when the object is destroyedYesYesOnly with destroyCancellationToken
Stops when the component is disabledYesNo (do it in OnDisable)No (cancel it yourself)
Return a value, use try/catchNoNoYes
Wait for other async APIsPoll each frameYield operationsAwait anything

Static state and domain reload#

Every script's static fields live in the scripting domain, the memory area that holds your loaded code. By default Unity reloads the domain each time you enter Play mode, which wipes all static state, so every Play session starts fresh, as a build does. Reloading takes time, though, and grows with the project, so Unity lets you turn it off: Project Settings → Editor → Enter Play Mode Settings, in the When entering Play Mode dropdown.

OptionDomain (statics)Scene
Reload Domain and SceneResetReloaded from disk
Reload Scene OnlyKept from the last sessionReloaded from disk
Reload Domain OnlyResetNot reloaded
Do not reload Domain or SceneKept from the last sessionNot reloaded

With domain reload off, the Manual lists what carries over between Play sessions: static fields keep their values, static events keep their subscribers, and non-serialized fields of objects that survive (such as ScriptableObject assets) keep their values. A static score that started at 0 starts at last session's total; a static event fires to subscribers from the previous session, whose objects have been destroyed. Your code has to reset its own statics. The standard way is a static method marked with [RuntimeInitializeOnLoadMethod], which Unity calls when Play mode starts whether or not the domain was reloaded:

Resetting staticsC#
public static int TotalDocked { get; private set; }
public static event Action<Ship> AnyShipDocked;

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics()
{
    TotalDocked = 0;
    AnyShipDocked = null;   // drop subscribers left over from the last session
}

SubsystemRegistration is the earliest point, before the first scene loads and before any Awake, so everything that subscribes afterwards does so to a clean event. Do this for every static field that changes during play, and for every static event. Fields that never change (constants, static readonly lookup tables built once) don't need it. Even if your project reloads the domain, writing the reset makes the code correct under either setting, and keeps the option of faster Play mode open.

Assembly definitions#

By default Unity compiles almost all your scripts into one assembly, Assembly-CSharp.dll. That works for small projects, but the Manual lists the drawbacks as projects grow: changing one script recompiles all of them, any script can use any other (so dependencies tangle), and everything is compiled for every platform. An Assembly Definition asset splits code into separate assemblies. Every script in the folder that holds it, and its subfolders, goes into that assembly, unless a subfolder has its own.

  • Create one with Assets → Create → Scripting → Assembly Definition in the folder whose scripts it should own, and name it (for example Lighthouse.Gameplay).
  • References are one-way. If Gameplay references Core, Gameplay can use Core's classes but not the reverse, and Unity refuses cycles. That makes the structure of your code visible and enforced.
  • Changing a script recompiles only its assembly and those that depend on it, which keeps iteration fast in bigger projects.
  • Editor code needs its own assembly. Once a folder has an assembly definition, an Editor subfolder inside it no longer goes to the special editor assembly automatically. Give it its own assembly definition with only the Editor platform ticked.
  • Code in your own assemblies can't see Assembly-CSharp. Once you start, move scripts in dependency order (core first), or you'll get "type not found" errors for classes still in the default assembly.

The handbook's sandbox uses one assembly per chapter. The build writes them for you; here's the one for T02 · The Unity Editor: Scenes, GameObjects, Components & Prefabs's editor tool, which shows the main fields:

Handbook.T02.Editor.asmdef (shortened)JSON
{
    "name": "Handbook.T02.Editor",
    "references": [
        "Handbook.T02"
    ],
    "includePlatforms": [
        "Editor"
    ],
    "autoReferenced": false
}

You don't edit the JSON by hand: select the asset and use the Inspector, which has a list of references and platform checkboxes. Don't split a small project into many assemblies on day one. Start when compile times start to hurt, or when you want to enforce that, say, your UI code can't reach into your save system.

Debugging tools#

T01 · Setting Up: Unity 6, Hub, IDE, Packages & Version Control set up your IDE's debugger, which lets you pause on a line and inspect every variable; use it whenever logging gets tedious. Unity adds its own tools:

  • Debug.Log, LogWarning, LogError write to the Console with an icon for each level. Pass an object as the second argument (Debug.Log("Hit", this)) and clicking the message highlights it in the Hierarchy. Debug.LogException(e) logs a caught exception with its stack trace.
  • The Console's toolbar: Collapse groups identical messages with a count, Clear on Play starts each session clean, and Error Pause pauses the game the moment an error is logged, so you can inspect the scene at the moment it went wrong.
  • Debug.DrawLine and Debug.DrawRay draw lines in the Scene view (and the Game view with Gizmos turned on) for one frame, or for a duration you pass. Perfect for seeing directions, targets and ranges.
  • OnDrawGizmos and OnDrawGizmosSelected are event functions for drawing permanent helpers in the Scene view with the Gizmos class, such as a sphere showing a turret's range.
  • Debug.Break() pauses the Editor from code, as if you'd pressed Pause; then step one frame at a time with the Step button next to Play.

Guided build: Lighthouse Island, night shift#

In T02 · The Unity Editor: Scenes, GameObjects, Components & Prefabs you built Lighthouse Island at dusk. Now it's night, and the harbour is busy: a buoy flashes its pattern at the harbour mouth, ships arrive one after another and sail to the jetty, and the harbour keeps count. You'll use the BeaconLight coroutine from above and three new scripts: one moving with Time.deltaTime and raising a static event, one spawning with async Awaitable and cancellation, and one subscribing in OnEnable and resetting its static state for any domain reload setting. If you skipped T02 · The Unity Editor: Scenes, GameObjects, Components & Prefabs's build, any scene with a large plane for the sea works.

Step 1 · Make it night#

  1. In the Project window, select _Project/Scenes/Island and press Ctrl+D to duplicate it. Rename the copy IslandNight and open it, so the dusk version stays as it was.
  2. Select the Directional Light (in Lighting). Set its colour to a dark blue and its Intensity to about 0.15. The lighthouse beams now stand out.
  3. Put the four scripts below, plus BeaconLight from the coroutines section, in _Project/Scripts. Each file name must match its class name.

Step 2 · A flashing buoy#

  1. Inside Props, create a Cylinder named Buoy at (6, 0.4, −34), scale (0.8, 0.6, 0.8), with the Red material. That's out past the jetty, at the harbour mouth.
  2. Add a child Point Light (GameObject → Light → Point Light) named Lamp at local (0, 1.6, 0). Set its colour to green, Range 12, Intensity about 4.
  3. Add BeaconLight to the Buoy. Because of its Reset method, the Beacon slot fills itself with the child Lamp. Set the pattern to . . - followed by a few spaces.
  4. Press Play. The buoy flashes short, short, long, pause. While playing, untick the BeaconLight component: the light goes off and stays off. Tick it again: it restarts. That's OnDisable and OnEnable doing their job.

Step 3 · The ship#

Ship.csUnity sandboxC#
using System;
using UnityEngine;

namespace Handbook.T05
{
    // Sails forward at a steady speed on any frame rate, and docks after a set distance.
    public class Ship : MonoBehaviour
    {
        public static event Action<Ship> AnyShipDocked;   // static: listeners need no reference to a ship

        [SerializeField, Min(0.1f)] float speed = 3f;          // metres per second
        [SerializeField, Min(1f)] float voyageLength = 25f;    // metres

        float travelled;

        // Runs when Play mode starts, even if the domain wasn't reloaded.
        [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
        static void ResetStatics() => AnyShipDocked = null;

        void Update()
        {
            float step = speed * Time.deltaTime;   // metres this frame
            transform.Translate(0f, 0f, step);     // along the ship's own forward axis
            travelled += step;

            if (travelled >= voyageLength)
            {
                AnyShipDocked?.Invoke(this);
                Destroy(gameObject);               // happens after this frame's Update loop
            }
        }
    }
}
  1. T02's boat has Bob, which sets its local position every frame to rock it. If you moved the same object with Ship, the two scripts would fight. So the ship is a parent that moves, and the bobbing boat is its child. Create an empty named Ship at (0, 0, 0). Duplicate one of the boats, drag the copy onto Ship, and set the copy's local position to (0, 0, 0).
  2. Add the Ship component to the parent. The boat's bow should point along the parent's blue Z axis; if it doesn't, rotate the child boat, not the parent.
  3. Drag Ship into _Project/Prefabs and delete it from the scene.

The ship moves speed * Time.deltaTime metres each frame, so a 25 m voyage at 3 m/s takes a little over 8 seconds on any machine. Translate moves along the object's own axes by default, so the ship sails wherever its spawner points it. Docking raises the static event and destroys the ship. The ship doesn't know who's counting.

Step 4 · The spawner#

ShipSpawner.csUnity sandboxC#
using System;
using UnityEngine;

namespace Handbook.T05
{
    // Launches a ship every few seconds, written with async/await instead of a coroutine.
    public class ShipSpawner : MonoBehaviour
    {
        [SerializeField] Ship shipPrefab;
        [SerializeField, Min(0.5f)] float interval = 4f;
        [SerializeField, Min(1)] int shipCount = 5;

        // Start can be async: Unity calls it as usual, and it carries on across frames.
        async Awaitable Start()
        {
            if (shipPrefab == null)
            {
                Debug.LogWarning($"{name}: no ship prefab assigned", this);
                return;
            }

            try
            {
                for (int i = 1; i <= shipCount; i++)
                {
                    Ship ship = Instantiate(shipPrefab, transform.position, transform.rotation);
                    ship.name = $"Ship {i}";
                    await Awaitable.WaitForSecondsAsync(interval, destroyCancellationToken);
                }
                Debug.Log($"{name}: all {shipCount} ships launched", this);
            }
            catch (OperationCanceledException)
            {
                // The spawner was destroyed, or Play mode ended, while waiting: stop quietly.
            }
        }
    }
}
  1. Inside Gameplay, create an empty named ShipSpawner at (4, 0, −42), rotation (0, 0, 0), so its forward axis points towards the island, just to the side of the jetty.
  2. Add the ShipSpawner component and drag the Ship prefab into its slot. Notice that the slot only accepts objects with a Ship component, because the field's type is Ship.

Starting 42 m out and sailing 25 m, each ship ends its voyage alongside the jetty, 4 m to its side. Every await passes destroyCancellationToken. Without it, deleting the spawner during Play would leave Start waiting, and when the wait ended, transform.position on the destroyed spawner would throw a MissingReferenceException. Step 6 lets you see that happen.

Step 5 · The harbour tally#

HarbourTally.csUnity sandboxC#
using UnityEngine;

namespace Handbook.T05
{
    // Counts ships as they dock. Put it on the jetty.
    public class HarbourTally : MonoBehaviour
    {
        // Static: shared by all tallies, and correct for any domain reload setting.
        public static int TotalDocked { get; private set; }

        [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
        static void ResetStatics() => TotalDocked = 0;

        void OnEnable() => Ship.AnyShipDocked += OnShipDocked;
        void OnDisable() => Ship.AnyShipDocked -= OnShipDocked;

        void OnShipDocked(Ship ship)
        {
            TotalDocked++;
            Debug.Log($"{ship.name} docked ({TotalDocked} so far)", this);
        }
    }
}
  1. Add HarbourTally to the Jetty object.
  2. Press Play and watch for about 30 seconds. Ships appear every four seconds out at sea, sail past the flashing buoy towards the jetty, and vanish as they dock, while the Console logs "Ship 1 docked (1 so far)" and so on, up to 5. Partway through, between the third and fourth docking, the spawner logs "all 5 ships launched": it finished its last wait before the last two ships arrived. Click a log line: the Jetty is highlighted, because the tally passes this as the context.

Step 6 · Break it on purpose#

Three experiments, each showing a rule from this chapter. Undo each change afterwards.

  1. Destroying during a wait. Press Play and, after the second ship appears, select ShipSpawner in the Hierarchy and delete it. Nothing goes wrong: the token cancelled the wait. Now remove the destroyCancellationToken argument from the await line, save, and repeat. Within four seconds of deleting the spawner, the Console shows a MissingReferenceException: the method woke up and touched a destroyed object.
  2. Disabling the listener. During Play, untick the HarbourTally component. Ships keep docking, but nothing is logged: OnDisable unsubscribed it. Tick it again and the count continues.
  3. Domain reload off. Open Project Settings → Editor and set When entering Play Mode to Reload Scene Only. Play twice: each session starts from "1 so far", because ResetStatics runs on every Play. Now comment out the [RuntimeInitializeOnLoadMethod] line in HarbourTally and play twice more: the second session continues from 6. That's what any unreset static does with domain reload off. Restore the line, and put the setting back the way you found it.

Step 7 · Save and commit#

Save the scene (Ctrl+S). In your terminal or Git client, check that the new scripts, their .meta files, the Ship prefab and the IslandNight scene are listed, then commit: git commit -m "Night shift".

Check your work. You've finished when: the buoy flashes a repeating pattern and stops cleanly when its component is disabled; five ships sail in at a steady speed, rocking as they go, and disappear at the jetty; the Console counts them from 1 to 5 on every Play, with either domain reload setting; and deleting the spawner mid-run produces no errors.

Going further#

The player loop#

Everything in the lifecycle diagram is part of Unity's player loop, a list of systems that run in order each frame. The UnityEngine.LowLevel.PlayerLoop API lets you read it and insert your own update steps, which advanced frameworks use to run code at precise points without a MonoBehaviour. You won't need it for a long time, but knowing it exists explains how Awaitable and many packages hook into the frame.

Thousands of objects#

Each Update call costs a little overhead for Unity to call into your script. With a few hundred objects that doesn't matter. With many thousands, a common pattern is one manager component that keeps a list of objects and updates them all in one loop. Beyond that, Unity's job system and ECS (T46 · DOTS: Entities (ECS), Jobs & Burst) are built for very large numbers.

Testing scripts#

The Unity Test Framework runs automated tests in Edit mode and Play mode, including tests that wait frames with coroutines. Code that separates logic from MonoBehaviours, as the console programs in T03 · C# Fundamentals: Types, Logic, Methods & Classes and T04 · C# Beyond Basics: OOP, Generics, Collections, LINQ, Delegates & Memory did, is far easier to test. T18 · Debugging, Automated Tests & a Git Workflow for Unity covers it.

Common mistakes & debugging#

My method never runs#

  • The name or capitalisation is wrong (update, Awak, OnCollisionEnter(Collider c) with the wrong parameter type). Event functions are found by exact name and signature.
  • The component is disabled, or its GameObject (or a parent) is inactive. Only Awake runs on a disabled component.
  • The script isn't attached to anything in the scene, or it's on a prefab asset rather than an instance.
  • A coroutine was called like a method (Flash();) instead of started (StartCoroutine(Flash());). Calling an iterator method only creates the iterator.
  • The Console has a compile error. Until every error is fixed, Unity keeps running the last version of your scripts that compiled.
  • A base and a derived class both declare a private Awake; only the derived one runs (see T04 · C# Beyond Basics: OOP, Generics, Collections, LINQ, Delegates & Memory).

Null and missing references#

  • Unassigned slot: the Inspector shows None. Drag the object in, or fill it in Reset, or check for null and log a clear warning.
  • Read too early: you read another object's state in Awake or OnEnable, before its Awake ran. Move the read to Start.
  • Destroyed object: something was destroyed while your code still held it. Check with != null; unsubscribe from events in OnDisable; pass destroyCancellationToken to awaits.
  • ?. on a Unity object: it doesn't see destruction. Replace with an explicit != null check.

It runs, but wrongly#

  • Speed depends on the frame rate: a per-frame amount without Time.deltaTime.
  • Everything freezes after unpausing: Time.timeScale was left at 0, or a WaitForSeconds is waiting on paused time. Use WaitForSecondsRealtime for things that run during pauses.
  • An Inspector value "reverts": you changed it during Play mode, and Play mode changes to scene objects are discarded (T02 · The Unity Editor: Scenes, GameObjects, Components & Prefabs). Or you changed the field initialiser in code and expected existing components to update; they keep their saved value.
  • A field doesn't appear in the Inspector: it's private without [SerializeField], static, readonly, a property, a dictionary, or a custom class without [Serializable].
  • Data lost after renaming a field: add [FormerlySerializedAs("oldName")].
  • Unity freezes on Play: an infinite loop, often a while in a coroutine that doesn't yield on every path, or a while waiting for something that can only change in a later frame.
  • Counts or events double up after a few Play sessions: domain reload is off and statics or static events aren't reset.
  • A camera that follows a target jitters: it moves in Update; move it in LateUpdate.

Exercises#

The Unity solutions are in the sandbox under Assets/Handbook/T05/Exercises, compiled against Unity 6.3.

1 · Predict the order

A scene has two objects, A and B, each with a script that logs Awake, OnEnable and Start. On B the script component is unticked. During Play, at frame 100, another script calls b.enabled = true and then Destroy(a.gameObject). List every message you'll see, in order, from Play to that point, and the ones after it. Which message do you never see from B, and why might that matter?

Solution

On load: A Awake, A OnEnable, B Awake (B's Awake runs even though its component is disabled; A's and B's may interleave differently, but both Awakes come before any Start), then A Start. At frame 100: B OnEnable (immediately, when enabled is set), then B Start before B's first Update; A OnDisable and A OnDestroy later in that frame, when the destruction actually happens (after the Update loop). B never logs anything at load except Awake: if B's Start did set-up that other objects depend on, they'd find it missing for the first 100 frames. Code that depends on another object should handle "not started yet".

2 · Frame-rate independent drifting

This script moves 0.05 m and turns 0.5° every frame. Rewrite it to move 2 m per second and turn 30° per second at any frame rate, with a checkbox that makes it keep drifting while the game is paused with Time.timeScale = 0.

BeforeC#
void Update()
{
    transform.Translate(0f, 0f, 0.05f);
    transform.Rotate(0f, 0.5f, 0f);
}
Solution
Exercises/Drifter.csUnity sandboxC#
using UnityEngine;

namespace Handbook.T05
{
    // Drifts forward and turns at fixed rates per second, whatever the frame rate.
    public class Drifter : MonoBehaviour
    {
        [SerializeField] float speed = 2f;          // metres per second
        [SerializeField] float turnSpeed = 30f;     // degrees per second
        [SerializeField] bool ignorePause;          // keep moving when Time.timeScale is 0

        void Update()
        {
            float dt = ignorePause ? Time.unscaledDeltaTime : Time.deltaTime;
            transform.Translate(0f, 0f, speed * dt);
            transform.Rotate(0f, turnSpeed * dt, 0f);
        }
    }
}

Rates are per second, multiplied by the frame's duration. unscaledDeltaTime ignores timeScale, which suits things like a menu background that should keep moving while the game is paused.

3 · What does the Inspector show?

For each field in this component, say whether Unity saves it and shows it in the Inspector, and why.

Quiz.csC#
public class Quiz : MonoBehaviour
{
    public int a;
    int b;
    [SerializeField] int c;
    public static int d;
    [SerializeField] readonly int e = 5;
    public Dictionary<string, int> f;
    [SerializeField] List<Vector3> g;
    public int H { get; set; }
    [field: SerializeField] public int I { get; private set; }
    [SerializeField] Wave j;                 // Wave is a plain class without [Serializable]
    [System.NonSerialized] public int k;
    [HideInInspector] public int l;
}
Solution

Saved and shown: a (public), c (SerializeField), g (a list of a Unity value type), I (its backing field is serialized). Saved but hidden: l. Neither: b (private, no attribute), d (static), e (readonly), f (dictionaries aren't supported), H (a property; its hidden backing field has no attribute), j (a custom class needs [Serializable]), k (explicitly excluded).

4 · A light that fades out

Write a LightFader component with a coroutine that fades a Light's intensity to zero over a set number of seconds, then turns the Light off. Calling it again while it's fading should restart the fade from the current brightness rather than run two fades at once. Add a context menu item to test it.

Solution
Exercises/LightFader.csUnity sandboxC#
using System.Collections;
using UnityEngine;

namespace Handbook.T05
{
    // Fades a Light to zero over a few seconds, then turns it off.
    public class LightFader : MonoBehaviour
    {
        [SerializeField] Light target;
        [SerializeField, Min(0.01f)] float duration = 2f;

        Coroutine fading;

        [ContextMenu("Fade Out")]
        public void FadeOut()
        {
            if (target == null) return;
            if (fading != null) StopCoroutine(fading);   // one fade at a time
            fading = StartCoroutine(Fade());
        }

        IEnumerator Fade()
        {
            target.enabled = true;
            float start = target.intensity;
            for (float t = 0f; t < duration; t += Time.deltaTime)
            {
                target.intensity = Mathf.Lerp(start, 0f, t / duration);
                yield return null;   // carry on next frame
            }
            target.intensity = 0f;
            target.enabled = false;
            fading = null;
        }
    }
}

Keeping the Coroutine handle is what allows a clean restart. Mathf.Lerp(a, b, t) blends from a to b as t goes from 0 to 1 (T06 · Game Maths: Vectors, Trigonometry, Matrices, Quaternions & Interpolation covers interpolation). Setting the final value after the loop guarantees it ends at exactly zero, whatever the last frame's timing.

5 · The same fade with Awaitable

Rewrite the fader with async Awaitable. It must stop if the object is destroyed mid-fade, and restarting must cancel the previous fade. Hint: a CancellationTokenSource linked to destroyCancellationToken.

Solution
Exercises/LightFaderAsync.csUnity sandboxC#
using System;
using System.Threading;
using UnityEngine;

namespace Handbook.T05
{
    // The fade with async/await. Cancelled by a restart or by destroying the object.
    public class LightFaderAsync : MonoBehaviour
    {
        [SerializeField] Light target;
        [SerializeField, Min(0.01f)] float duration = 2f;

        CancellationTokenSource current;

        [ContextMenu("Fade Out")]
        public void FadeOut()
        {
            if (target == null) return;
            current?.Cancel();    // stop a fade that's already running (a plain C# object, so ?. is fine)
            current?.Dispose();
            current = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
            _ = Fade(current.Token);   // start it; nothing needs to wait for it
        }

        async Awaitable Fade(CancellationToken token)
        {
            try
            {
                target.enabled = true;
                float start = target.intensity;
                for (float t = 0f; t < duration; t += Time.deltaTime)
                {
                    target.intensity = Mathf.Lerp(start, 0f, t / duration);
                    await Awaitable.NextFrameAsync(token);
                }
                target.intensity = 0f;
                target.enabled = false;
            }
            catch (OperationCanceledException)
            {
                // Restarted, or the object was destroyed: stop without an error.
            }
        }

        void OnDestroy() => current?.Dispose();
    }
}

A linked token source is cancelled when either its own Cancel is called or the token it's linked to (destroyCancellationToken) is cancelled, so one token covers both reasons to stop. _ = Fade(…) starts the method and deliberately discards the returned Awaitable; that's acceptable here because the method catches its own cancellation and nothing else needs to know when it ends.

6 · Fix the homing missile

This missile is supposed to turn towards its target and fly forward. It flies at different speeds on different machines, does needless work every frame, and throws errors when its target is destroyed. Find every problem and rewrite it, with turn rate and speed in the Inspector.

Homing.cs, beforeC#
public class Homing : MonoBehaviour
{
    public Transform target;

    void Update()
    {
        var body = GetComponent<Rigidbody>();
        transform.LookAt(target?.position ?? transform.position + transform.forward);
        transform.position += transform.forward * 0.2f;
    }
}
Solution

Problems: the target is a public field anyone can change (use [SerializeField] plus a method); GetComponent runs every frame and its result isn't even used; target?.position doesn't notice a destroyed target, so .position throws a MissingReferenceException; LookAt snaps instantly instead of turning at a rate; and 0.2 m per frame depends on the frame rate.

Exercises/Homing.csUnity sandboxC#
using UnityEngine;

namespace Handbook.T05
{
    // Flies forward and turns towards its target, while the target exists.
    public class Homing : MonoBehaviour
    {
        [SerializeField] Transform target;
        [SerializeField, Min(0f)] float speed = 8f;                   // metres per second
        [SerializeField, Min(0f)] float turnDegreesPerSecond = 90f;

        public void SetTarget(Transform newTarget) => target = newTarget;

        void Update()
        {
            if (target != null)   // Unity's ==: false once the target is destroyed
            {
                Vector3 toTarget = target.position - transform.position;
                if (toTarget.sqrMagnitude > 0.0001f)
                {
                    Quaternion wanted = Quaternion.LookRotation(toTarget);
                    transform.rotation = Quaternion.RotateTowards(
                        transform.rotation, wanted, turnDegreesPerSecond * Time.deltaTime);
                }
            }
            transform.position += transform.forward * (speed * Time.deltaTime);
        }
    }
}

Quaternion.RotateTowards turns by at most the given number of degrees, which, multiplied by Time.deltaTime, becomes a turn rate per second (T06 · Game Maths: Vectors, Trigonometry, Matrices, Quaternions & Interpolation explains quaternions). The length check avoids asking for a rotation towards a zero-length direction when the missile is exactly on its target.

7 · A score that survives too long

A project keeps the score in a static class with a static Score and a static ScoreChanged event. With domain reload off, the second Play session starts with the old score, and the score label from the previous session (now destroyed) throws an error when the event fires. Write the class so it works with any Enter Play Mode setting.

Solution
Exercises/ScoreKeeper.csUnity sandboxC#
using System;
using UnityEngine;

namespace Handbook.T05
{
    // A global score with a static event, correct with domain reload on or off.
    public static class ScoreKeeper
    {
        public static int Score { get; private set; }
        public static event Action<int> ScoreChanged;

        public static void Add(int points)
        {
            Score += points;
            ScoreChanged?.Invoke(Score);
        }

        [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
        static void ResetStatics()
        {
            Score = 0;
            ScoreChanged = null;   // forget last session's (destroyed) listeners
        }
    }
}

Two fixes in one method: the value is reset, and the event's subscriber list is cleared. The label should still subscribe in OnEnable and unsubscribe in OnDisable; the reset is the safety net for anything that didn't, and for the stale state domain reload would otherwise have cleared. A setter that is private means only Add can change the score.

8 · Waves

Write a WaveSpawner that runs a list of waves set up in the Inspector. Each wave has an enemy prefab (with a Health component), a count and a delay between spawns. Spawn points come from an array of Transforms. The next wave starts a few seconds after every enemy of the current wave has died, and dead enemies are removed. Use async Awaitable and make it stop cleanly if the spawner is destroyed.

Solution
Exercises/WaveSpawner.csUnity sandboxC#
using System;
using System.Collections.Generic;
using UnityEngine;

namespace Handbook.T05
{
    // Runs waves in order. The next wave starts after every enemy of the last one has died.
    public class WaveSpawner : MonoBehaviour
    {
        [Serializable]
        public class Wave
        {
            public Health enemyPrefab;
            [Min(1)] public int count = 3;
            [Min(0f)] public float secondsBetweenSpawns = 0.5f;
        }

        [SerializeField] List<Wave> waves = new List<Wave>();
        [SerializeField] Transform[] spawnPoints;
        [SerializeField, Min(0f)] float pauseBetweenWaves = 3f;

        int alive;

        async Awaitable Start()
        {
            if (spawnPoints == null || spawnPoints.Length == 0)
            {
                Debug.LogWarning($"{name}: no spawn points", this);
                return;
            }

            try
            {
                for (int w = 0; w < waves.Count; w++)
                {
                    Debug.Log($"Wave {w + 1} of {waves.Count}", this);
                    await SpawnWave(waves[w]);
                    while (alive > 0)
                    {
                        await Awaitable.NextFrameAsync(destroyCancellationToken);
                    }
                    await Awaitable.WaitForSecondsAsync(pauseBetweenWaves, destroyCancellationToken);
                }
                Debug.Log($"{name}: all waves cleared", this);
            }
            catch (OperationCanceledException)
            {
                // Destroyed or left Play mode mid-wave.
            }
        }

        async Awaitable SpawnWave(Wave wave)
        {
            if (wave.enemyPrefab == null) return;

            for (int i = 0; i < wave.count; i++)
            {
                Transform point = spawnPoints[i % spawnPoints.Length];
                Health enemy = Instantiate(wave.enemyPrefab, point.position, point.rotation);
                alive++;
                enemy.Died += () =>
                {
                    alive--;
                    Destroy(enemy.gameObject);
                };
                await Awaitable.WaitForSecondsAsync(wave.secondsBetweenSpawns, destroyCancellationToken);
            }
        }
    }
}

Each wave is a [Serializable] class shown as a foldout in the Inspector list. SpawnWave is its own async method, awaited once per wave. The enemy count goes up on spawn and down in a Died handler; the lambda captures enemy so it can destroy the right object (the enemy is the publisher and is destroyed with its handler, so nothing leaks). To test, make an enemy prefab with Health and a low max health, and use its Take 10 Damage menu during Play. Waiting by checking alive every frame is simple and fine here; AwaitableCompletionSource could instead complete the wave the moment the last enemy dies.

Check yourself#

A script's component checkbox is unticked, but its GameObject is active. Which of its event functions runs when the scene loads?

  1. None
  2. Awake only
  3. Awake and OnEnable
  4. Awake, OnEnable and Start

Awake runs whenever the GameObject becomes active, whatever the component's enabled state. OnEnable and Start wait until the component is enabled.

Where should a script read a value that another object sets up in its own Awake?

  1. Its constructor
  2. Awake
  3. OnEnable
  4. Start

No Start runs until every Awake in the scene has run, so Start is the first safe moment to read other objects' state.

An object should move 5 m per second. In Update, step is how far it moves this frame. Which is right?

  1. step = 5f * Time.deltaTime;
  2. step = 5f;
  3. step = 5f / Time.deltaTime;
  4. step = 5f * Time.fixedDeltaTime;

Metres per second × seconds this frame = metres this frame. fixedDeltaTime is the physics step, not the length of this frame.

Time.timeScale is set to 0 for a pause menu. What still works?

  1. FixedUpdate
  2. A coroutine waiting on WaitForSeconds(1f)
  3. Code using Time.unscaledDeltaTime in Update
  4. Movement using Time.deltaTime

Update still runs, and unscaled time keeps advancing. FixedUpdate and WaitForSeconds coroutines stop, and deltaTime is 0.

enemy refers to a component that has been destroyed. What does enemy?.TakeDamage(5) do?

  1. Nothing: ?. sees that it's null
  2. Calls TakeDamage, which fails if it touches the engine
  3. A compile error
  4. Recreates the enemy

?. checks only the C# reference, which still exists. Unity's overridden == would report null; use if (enemy != null).

Which field does Unity save and show in the Inspector?

  1. private int lives;
  2. public static int lives;
  3. public Dictionary<string, int> lives;
  4. [SerializeField] private int lives;

Private fields need [SerializeField]; statics and dictionaries are never serialized.

You change int maxHealth = 100; to = 150 in code. What happens to the enemies already placed in your scene?

  1. They keep 100, their saved value
  2. They all become 150
  3. They become 0
  4. Unity shows a warning and asks

An initialiser is only the default for new components. Saved values win; change the prefab or use Reset to update existing ones.

A coroutine was started in OnEnable. Which action does not stop it unless you stop it yourself?

  1. Deactivating the GameObject
  2. Destroying the component
  3. Unticking the component's checkbox
  4. Unloading the scene

Disabling a component doesn't stop its coroutines; deactivation and destruction do. Stop it in OnDisable to cover all cases.

An async Awaitable method awaits WaitForSecondsAsync(5f) with no token, and its object is destroyed after 2 seconds. What happens?

  1. The method stops with the object
  2. It resumes after 5 seconds and fails as soon as it touches the destroyed object
  3. Unity waits until the method finishes before destroying the object
  4. A compile error

Async methods aren't tied to their object. Pass destroyCancellationToken so the wait is cancelled with the object.

With Reload Scene Only, what happens to a static int that counted to 7 in the last Play session?

  1. It's reset to 0 automatically
  2. It's reset to its initialiser
  3. It's saved into the scene
  4. It's still 7 unless your code resets it

Without a domain reload, statics keep their values. Reset them with a [RuntimeInitializeOnLoadMethod] method.

Cheat sheet#

Lifecycle

  • Awake: set up yourself (runs even if disabled)
  • OnEnable / OnDisable: subscribe / unsubscribe
  • Start: talk to others (after every Awake)
  • FixedUpdate: physics, 0.02 s steps
  • Update: per-frame logic
  • LateUpdate: follow cameras
  • OnDestroy: final cleanup

Time

  • rate * Time.deltaTime per frame
  • Time.unscaledDeltaTime ignores pause
  • Time.timeScale = 0 pauses
  • Cooldown: Time.time >= next
  • [DefaultExecutionOrder(-100)]

References

  • [SerializeField] Light lamp; first choice
  • Cache GetComponent<T>() in Awake
  • TryGetComponent(out T c)
  • [RequireComponent(typeof(T))]
  • FindAnyObjectByType<T>(): never per frame
  • CompareTag("Player")

Unity null and objects

  • if (target != null) or if (target)
  • No ?. or ?? on Unity objects
  • Instantiate(prefab, pos, rot)
  • Destroy(gameObject, delay)
  • SetActive(false) vs enabled = false

Serialization

  • Public or [SerializeField]; not static/const/readonly
  • Lists and arrays yes; dictionaries no
  • [Serializable] on your data classes
  • [field: SerializeField] for auto-properties
  • [FormerlySerializedAs("old")]
  • [Header], [Tooltip], [Range], [Min]

Coroutines

  • IEnumerator Run() with yield return …
  • var c = StartCoroutine(Run());
  • StopCoroutine(c); in OnDisable
  • Cache new WaitForSeconds(t)
  • WaitForSecondsRealtime during pause

Awaitable

  • async Awaitable Start()
  • await Awaitable.NextFrameAsync(token);
  • WaitForSecondsAsync(s, token)
  • Token: destroyCancellationToken
  • Catch OperationCanceledException
  • Await each Awaitable once

Statics and assemblies

  • Reset with [RuntimeInitializeOnLoadMethod(…)]
  • Load type: SubsystemRegistration
  • Clear static events: Evt = null;
  • Create → Scripting → Assembly Definition
  • Editor code: own asmdef, Editor platform

Glossary#

MonoBehaviour
The base class for script components. Unity creates instances and calls their event functions.
Event function
A method with a special name, such as Awake or Update, that Unity finds by name and calls at a set moment. Listed as Messages in the API reference.
Frame
One pass of Unity's loop: input, game logic, animation and rendering, many times a second.
Time.deltaTime
The duration of the last frame in seconds, scaled by Time.timeScale; used to make change per second independent of frame rate.
Fixed timestep
The constant interval (0.02 s by default) at which FixedUpdate and physics run, independent of frame rate.
Time.timeScale
How fast game time passes relative to real time; 0 pauses scaled time.
Script execution order
A per-script setting that makes some scripts' event functions run before or after others.
Fake null
A Unity object whose native part is gone (destroyed) or a placeholder for an unassigned field; it equals null with == but not with ?., ?? or is null.
Serialization
Turning object data into a storable form and back. Unity serializes component fields into scenes and prefabs, and shows serialized fields in the Inspector.
[SerializeField]
An attribute that makes Unity serialize and show a private field.
[Serializable]
An attribute that lets Unity serialize your own class or struct when it's used as a field.
[SerializeReference]
An attribute that serializes a field by reference, allowing null, shared objects and derived types.
ScriptableObject
A class whose instances are data assets in the project rather than components on GameObjects.
Coroutine
An iterator method started with StartCoroutine that Unity resumes after each yield return, spreading work across frames.
Yield instruction
An object returned from a coroutine that says when to resume, such as WaitForSeconds.
async / await
C# features for methods that pause at an await and continue later without blocking.
Awaitable
Unity 6's pooled awaitable type, with methods to wait for frames, seconds, physics steps and threads.
Cancellation token
An object passed to async calls that, when cancelled, makes them throw OperationCanceledException to stop the work.
destroyCancellationToken
A MonoBehaviour property: a token cancelled when that component is destroyed.
Main thread
The thread that runs the frame loop and every event function; most Unity APIs may only be used on it.
Domain reload
Unloading and reloading all script code, which resets static state. Optional when entering Play mode.
RuntimeInitializeOnLoadMethod
An attribute marking a static method Unity calls at startup, used to reset statics at the start of each Play session.
Assembly Definition
An asset that compiles the scripts in its folder into a separate assembly with explicit references.
Log context
The object passed as the second argument to Debug.Log, highlighted when you click the message.