4.1 Unity Core: GameObjects, Components, Scenes, Prefabs

Phase 4 · Unity (primary engine) · Study time: 30–50 h

The GameObject/Component model, scenes, prefabs and the inspector — how a Unity game is actually structured and assembled.

Every chapter so far has been plain code: a program you compile and run from a terminal, with input and output as text. Unity is different. It is a big application with a visual editor, and your C# scripts do not run alone — they run attached to objects inside a scene that Unity itself manages. This chapter is the on-ramp: the handful of ideas everything else in Unity is built from. Get comfortable with these and the rest of the engine starts to make sense; skip past them and every later chapter feels like guessing.

Studios like HoYoverse build their games on Unity with C#, so this is not a toy version of "real" engine work — the GameObject/Component/Prefab model below is exactly what production Unity projects look like on the inside.

This chapter has fewer terminal outputs than earlier ones, because a lot of what you do in Unity happens by clicking in windows, not by running a program from a command line. Where there is a script, we still show it and its expected Debug.Log output in the Console window, the same way earlier chapters showed std::cout output.

1. The Unity Editor: the windows you live in

Unity is one application called the Editor. When you open a project (a folder on disk holding your game's files), the Editor arranges itself into several windows. You will keep the same five open almost all the time:

+------------------------+------------------------------+ | Hierarchy | Scene view / Game view | | (tree of GameObjects | (Scene: editing camera | | in the open scene) | Game: what the player sees) | | | | | Main Camera | | | Directional Light | | | Player | | | Weapon | | | Enemies | | | Goblin_01 | | +------------------------+------------------------------+ | Project | Inspector | | Assets/ | Player (selected) | | Scripts/ | Transform | | Prefabs/ | Mesh Renderer | | Scenes/ | PlayerController (script) | +------------------------+------------------------------+

Selecting a GameObject works the same everywhere: click it in the Hierarchy, or click it directly in the Scene view, and the Inspector immediately updates to show that object's components. This one habit — select in Hierarchy or Scene, read/edit in Inspector — is most of what you do while building a level.

Tip If you ever lose a window (closed it by accident), the Window menu at the top of the Editor lists every window and lets you reopen it.

2. GameObject: an empty container that does nothing by itself

A GameObject is the one kind of "thing" that exists in a Unity scene. Every character, light, camera, wall, and invisible trigger zone is a GameObject. On its own, a brand-new GameObject is almost nothing: it has a name, and it has a position in space — that is it. It has no shape, draws nothing on screen, and has no behavior.

Think of a GameObject as an empty box. An empty box does nothing interesting; what makes it useful is what you put inside it. In Unity, the things you put inside a GameObject are called components.

an empty GameObject, right after creating it: GameObject "Empty" +--------------------------+ | Transform | <- the only thing every GameObject always has +--------------------------+ the SAME GameObject after adding components: GameObject "Player" +--------------------------+ | Transform | position / rotation / scale | Mesh Renderer | makes it visible on screen | Box Collider | gives it a physical shape | PlayerController (script) | your own C# behavior +--------------------------+

You can create an empty GameObject from the menu (GameObject > Create Empty) or by right-clicking inside the Hierarchy window. In code, new GameObject(...) does the same thing at runtime:

using UnityEngine;

public class SpawnExample : MonoBehaviour
{
    void Start()
    {
        GameObject empty = new GameObject("MyEmptyObject");
        Debug.Log(empty.name);
        Debug.Log(empty.transform.position);
    }
}

Console output when this runs:

MyEmptyObject
(0.0, 0.0, 0.0)

Notice the second line: even though we asked for a totally empty GameObject, it still has a transform with a position. That is because every GameObject automatically has a Transform component — Unity attaches it for you and will not let you remove it. Everything else — how it looks, how it moves, how it reacts to physics, your own gameplay logic — has to be added on top.

3. Components: composition, not inheritance

In earlier chapters, giving an object new behavior usually meant writing a new class that inherits from another one — a chain like Enemy : Character : Entity. Unity mostly does not work that way. Instead of stacking behavior through inheritance, you build a GameObject by attaching several small, independent components to it. This style is called composition — you compose (assemble) an object out of parts, instead of inheriting it from a parent class.

INHERITANCE (deep chain of classes - NOT how Unity behavior works) Entity | Character | Enemy | FlyingEnemy <- adding "can fly" means writing a whole new subclass COMPOSITION (Unity's way - assemble from independent parts) GameObject "Enemy" - Transform - Mesh Renderer - EnemyHealth (component) - EnemyAI (component) - FlightMovement (component) <- "can fly" is just one more component

Each component is its own C# class. A script you write and attach to a GameObject is a component too — it just needs to inherit from Unity's MonoBehaviour base class (that is the one piece of inheritance Unity asks of you; from there you compose, you do not keep subclassing). Here is a minimal component:

using UnityEngine;

public class Health : MonoBehaviour   // a Component you can attach to any GameObject
{
    public int maxHP = 100;
    public int currentHP;

    void Awake()   // Unity calls this once, automatically, when the object loads
    {
        currentHP = maxHP;
        Debug.Log(gameObject.name + " has " + currentHP + " HP");
    }
}

Attach this same Health script to two different GameObjects, "Knight" and "Slime", and press Play. Console output:

Knight has 100 HP
Slime has 100 HP

It is the same component class, but each GameObject that carries it gets its own separate copy of the fields. Knight's currentHP and Slime's currentHP are two different variables in memory — damaging the Knight never touches the Slime's HP. That separation is the whole point of composition: components are self-contained, reusable pieces you can drop onto any GameObject, and each instance keeps its own state.

Tip Awake() and Start() are both called automatically by Unity, once, when a GameObject becomes active — you never call them yourself. Awake() runs first (good for setting up your own state), Start() runs right after, just before the first frame (good for talking to other components, since by then everyone's Awake has already run). A later chapter covers the full order of these callbacks.

4. The Transform component: position, rotation, scale

Every GameObject's Transform stores three things, each a Vector3 (three numbers: x, y, z):

Unity's world uses Y as up, X as right, and Z as forward:

Y (up) | | +------ X (right) / Z (forward)

You read and write all three through transform, a reference every MonoBehaviour already has to its own GameObject's Transform component:

using UnityEngine;

public class TransformDemo : MonoBehaviour
{
    void Start()
    {
        transform.position = new Vector3(2f, 0f, 5f);     // move to (2,0,5)
        transform.eulerAngles = new Vector3(0f, 90f, 0f);  // face 90 degrees around Y
        transform.localScale = new Vector3(2f, 2f, 2f);    // twice as big

        Debug.Log("pos=" + transform.position);
        Debug.Log("rot=" + transform.eulerAngles);
        Debug.Log("scale=" + transform.localScale);
    }
}
pos=(2.0, 0.0, 5.0)
rot=(0.0, 90.0, 0.0)
scale=(2.0, 2.0, 2.0)

You will also see this same information as editable number boxes in the Inspector any time a GameObject is selected — the Inspector's Transform fields and the transform.position/eulerAngles/localScale code above are two views of the exact same data.

5. Parenting: local space vs world space

The Hierarchy window is a tree because GameObjects can be parented: drag one GameObject onto another in the Hierarchy, and the dragged one becomes a child of the one you dropped it on. This changes what a child's Transform numbers mean: they stop being world position and become local position — an offset relative to the parent, not relative to the origin of the whole scene.

Hierarchy tree: Car world position: (10, 0, 0) |- Wheel_FrontLeft local position: (1, -0.5, 2) |- Wheel_FrontRight local position: (-1, -0.5, 2) Wheel_FrontLeft's WORLD position = Car's world position + Wheel's local position = (10, 0, 0) + (1, -0.5, 2) = (11, -0.5, 2)

The payoff: move or rotate the Car, and both wheels move and rotate with it automatically, because their positions are stored as an offset from the Car, not as fixed numbers in the world. This is exactly why you parent things — group objects that should move together under one parent, and moving the parent is enough.

In code, transform.position is always world space; transform.localPosition is relative to the parent (and equals world position for an object with no parent):

using UnityEngine;

public class ParentDemo : MonoBehaviour
{
    void Start()
    {
        Debug.Log("local pos: " + transform.localPosition);
        Debug.Log("world pos: " + transform.position);
    }
}

Attached to Wheel_FrontLeft from the diagram above, Console output:

local pos: (1.0, -0.5, 2.0)
world pos: (11.0, -0.5, 2.0)
Common mistake Setting transform.position when you meant transform.localPosition (or the reverse). On an object with no parent they are identical, so the bug hides until the object gets parented later — then it suddenly jumps to the wrong spot. If a child object is in the wrong place only after you added a parent, check which of the two you are using.

6. Adding components in the Inspector

Before touching code, it helps to see the manual workflow, because you will use it constantly even in a code-heavy project. Select a GameObject in the Hierarchy. The Inspector shows its current components, ending with an Add Component button. Click it, type the name of the component you want (for example "Rigidbody", a built-in physics component), and click it in the list — it is now attached, with its own fields shown right there in the Inspector.

This also applies to your own scripts. Any public field on a MonoBehaviour (or a private field marked [SerializeField]) automatically shows up as an editable box in the Inspector — no extra work needed. Take the Health script from section 3:

Inspector for GameObject "Goblin" +----------------------------------------+ | Transform | | Position X:0 Y:0 Z:0 | | Rotation X:0 Y:0 Z:0 | | Scale X:1 Y:1 Z:1 | +----------------------------------------+ | Health (Script) | | Max Hp [ 100 ] | +----------------------------------------+ | [ Add Component ] | +----------------------------------------+

maxHP shows up as "Max Hp" — Unity turns the field name into a readable label. A designer (or you, without touching code again) can change that number per-GameObject directly in the Inspector. To remove a component, click the small gear icon in its header and choose Remove Component (Transform is the one component that never offers this option — it cannot be removed).

Tip Both maxHP and currentHP in that script are public, so both appear in the Inspector — including currentHP updating live while the game runs in Play mode, a handy way to watch a value change without a single Debug.Log.

7. Adding and finding components in C#: AddComponent and GetComponent

Code needs the same two abilities. GetComponent<T>() looks on the same GameObject for a component of type T and returns a reference to it, or null if that GameObject does not have one. AddComponent<T>() attaches a brand-new component of type T to a GameObject right now, while the game is running.

using UnityEngine;

public class ComponentDemo : MonoBehaviour
{
    void Start()
    {
        // Look for a component that should already be on this GameObject.
        Health hp = GetComponent<Health>();
        if (hp != null)
            Debug.Log("Found Health, maxHP = " + hp.maxHP);
        else
            Debug.Log("No Health component here.");

        // Attach a brand new component at runtime.
        Rigidbody rb = gameObject.AddComponent<Rigidbody>();
        rb.mass = 5f;
        Debug.Log("Added Rigidbody, mass = " + rb.mass);
    }
}

On a GameObject that already has Health attached (from section 3) and no Rigidbody yet, Console output:

Found Health, maxHP = 100
Added Rigidbody, mass = 5

If that GameObject did not have a Health component, the first line would print "No Health component here." instead of crashing — because we checked for null first.

Common mistake Skipping the null check and writing GetComponent<Health>().maxHP directly. If that GameObject has no Health component, GetComponent returns null, and asking null for .maxHP throws a NullReferenceException — probably the single most common error message you will see as a new Unity programmer. Always check for null when you are not certain the component is there.

GetComponent only looks on the exact GameObject you call it on — it does not search children or parents. When you need that, Unity provides GetComponentInChildren<T>() and GetComponentInParent<T>(), which walk the Hierarchy tree from that GameObject downward or upward.

8. Scenes: a tree of GameObjects, saved and loaded

A Scene is a single file (ending in .unity) that stores a whole tree of GameObjects — their components, their parent/child relationships, everything. A "level", a main menu screen, and a settings screen are usually each their own Scene. The Hierarchy window always shows the tree of whichever Scene is currently open.

Scene: "Level1" |- Main Camera |- Directional Light |- Environment | |- Ground | |- Trees | |- Tree_01 | |- Tree_02 |- Player |- Enemies |- Goblin_01 |- Goblin_02

To switch scenes while the game is running, use UnityEngine.SceneManagement.SceneManager:

using UnityEngine;
using UnityEngine.SceneManagement;   // needed to load scenes

public class LevelExit : MonoBehaviour
{
    public void GoToLevel2()
    {
        Debug.Log("Loading Level2...");
        SceneManager.LoadScene("Level2");   // must be listed in Build Settings
    }
}

There is no single console line to trace here — what happens is structural: calling LoadScene("Level2") tears down every GameObject in the currently open scene and replaces the whole Hierarchy tree with the one saved inside Level2. Anything you wanted to keep across that switch (score, inventory) has to live somewhere that is not part of the scene being unloaded — a topic for a later chapter.

Tip SceneManager.LoadScene(name, LoadSceneMode.Additive) loads a scene on top of the current one instead of replacing it — useful for things like loading a UI overlay scene without unloading the level underneath.
Common mistake Calling SceneManager.LoadScene("Level2") when "Level2" was never added to File > Build Settings > Scenes In Build. It can work fine inside the Editor and then fail in an actual build, because only scenes listed in Build Settings get included when the game is compiled.

9. Prefabs: build once, stamp out many

Say you build a fully configured "Goblin" GameObject — Transform, model, Health, an AI script — and your level needs thirty of them. Copy-pasting thirty times works until you find a bug in the AI script: now you have to fix it in thirty places. A Prefab solves this. Drag a configured GameObject from the Hierarchy into the Project window, and Unity saves it as a reusable template file (.prefab). Every copy you place after that is an instance linked back to that one template.

Project window: Assets/Prefabs/Goblin.prefab (the template, saved on disk) Scene Hierarchy (Level1): Goblin (instance) -> linked to Goblin.prefab Goblin (instance) -> linked to Goblin.prefab Goblin (instance) -> linked to Goblin.prefab Edit Goblin.prefab once (e.g. Max Hp 100 -> 150) | v ALL THREE instances update to Max Hp 150 automatically

To create instances from code — the usual way to spawn enemies, bullets, pickups — use Instantiate:

using UnityEngine;

public class GoblinSpawner : MonoBehaviour
{
    public GameObject goblinPrefab;   // drag Goblin.prefab onto this slot in the Inspector

    void Start()
    {
        for (int i = 0; i < 3; i++)
        {
            Vector3 spot = new Vector3(i * 2f, 0f, 0f);
            GameObject g = Instantiate(goblinPrefab, spot, Quaternion.identity);
            g.name = "Goblin_" + i;
            Debug.Log("Spawned " + g.name + " at " + spot);
        }
    }
}
Spawned Goblin_0 at (0.0, 0.0, 0.0)
Spawned Goblin_1 at (2.0, 0.0, 0.0)
Spawned Goblin_2 at (4.0, 0.0, 0.0)

Instantiate(prefab, position, rotation) makes a full, independent copy of everything saved in the prefab — every component, every field value — and drops it into the current scene at the given position. Quaternion.identity means "no rotation". Each of the three goblins now exists as its own GameObject with its own Health.currentHP, exactly like the Knight and Slime in section 3.

Common mistake Leaving the goblinPrefab field empty in the Inspector. It compiles fine, but at runtime Instantiate(goblinPrefab, ...) is really Instantiate(null, ...), which throws an error the moment Start() runs. If a spawner "does nothing", check first whether its prefab slot is actually assigned.

10. Prefab variants and overrides

Sometimes you want an object that is almost the same as a prefab but not quite — a "Goblin Boss" with more health and a bigger scale. Unity gives you two ways to do this without duplicating the whole prefab.

Instance override

Select one placed instance in the Hierarchy and change a field directly — say, its Max Hp from 100 to 500. The Inspector marks that field (usually with a bold label or a small bar on the left) to show it is now an override: this one instance differs from its prefab on that field, while everything else about it still follows the prefab.

Prefab Variant

Right-click a prefab in the Project window and choose Create > Prefab Variant. A Variant is a new, separate prefab asset that inherits everything from its base prefab, but can override specific fields (or even add extra components) of its own. Crucially, a Variant keeps following the base prefab for everything it did not override.

Goblin.prefab (base) Max Hp = 100, Scale = 1 +-- plain instance in Level1, with an INSTANCE OVERRIDE: | Max Hp = 500 (this one field only) | +-- GoblinBoss.prefab, a VARIANT of Goblin: Max Hp = 500 <- overridden in the variant Scale = 1.5 <- overridden in the variant EnemyAI, Mesh Renderer, everything else <- still inherited from Goblin.prefab

Now change the AI logic inside Goblin.prefab itself. Both the plain instance and GoblinBoss.prefab pick up that change automatically, because neither of them overrode the AI component — only Max Hp and Scale were ever marked as different.

Common mistake An overridden field shows two buttons in the Inspector: Revert (throw away the override, go back to matching the prefab) and Apply (push this instance's value back into the base prefab, which changes every other instance and variant too). Clicking Apply by accident on a value you only wanted to change for one object silently rewrites the shared template for the whole project. Read which button you are clicking before you press it.

11. A first script that moves a GameObject

Time to combine everything: a component that changes its own Transform every frame. Unity calls a few method names on every MonoBehaviour automatically, at specific times — you never call them yourself. Update() is the big one: Unity calls it once per rendered frame, for every enabled script in the scene.

using UnityEngine;

public class SimpleMover : MonoBehaviour
{
    public float speed = 3f;   // units per second, editable in the Inspector

    void Update()               // Unity calls this once per frame, automatically
    {
        float step = speed * Time.deltaTime;   // deltaTime = seconds since last frame
        transform.Translate(Vector3.forward * step);
        Debug.Log("moved by " + step + ", now at " + transform.position);
    }
}

Attach this to any GameObject and press Play. There is no fixed console output to copy here, because Time.deltaTime depends on how long each frame actually took — but you can trace it. At roughly 60 frames per second, each frame takes about 0.016 seconds:

frame 1: dt=0.016s step=0.048 pos.z: 0.000 -> 0.048 frame 2: dt=0.017s step=0.051 pos.z: 0.048 -> 0.099 frame 3: dt=0.015s step=0.045 pos.z: 0.099 -> 0.144 ... after about 60 frames (~1 second): pos.z is close to 3.0

transform.Translate(Vector3.forward * step) moves the object forward by step units, adding to wherever it already is. Because step is speed * Time.deltaTime, the object covers speed units of distance every real second, no matter how many frames that took — a fast frame contributes a small step, a slow frame contributes a bigger one, and it evens out.

Common mistake Writing transform.Translate(Vector3.forward * speed) and forgetting Time.deltaTime. That moves the object speed units every single frame instead of every second. On a machine running 30 frames per second it moves half as far per second as on a machine running 60 — your game runs at a different speed depending on whose computer it is on. Multiplying by Time.deltaTime is what makes movement frame-rate independent.
Tip Vector3.forward is shorthand for new Vector3(0f, 0f, 1f). Unity also provides Vector3.up, Vector3.right, and their negatives, so you rarely type raw Vector3 axis values by hand.

12. Glossary

13. Exercises

Exercise 1 You attach this script to two GameObjects named "Knight" and "Slime":
using UnityEngine;

public class Health : MonoBehaviour
{
    public int maxHP = 100;
    public int currentHP;

    void Awake()
    {
        currentHP = maxHP;
    }

    public void TakeDamage(int amount)
    {
        currentHP -= amount;
        Debug.Log(gameObject.name + " now has " + currentHP + " HP");
    }
}
At runtime, some other script calls knight.GetComponent<Health>().TakeDamage(30), and nothing else. What does the Console print, and why does the Slime's HP stay untouched even though it has the exact same script attached? Answer in one or two sentences.
Show answer

The Console prints exactly one line:

Knight now has 70 HP

Only TakeDamage was called on the Knight's Health component, so only the Knight's currentHP field changes. Even though "Slime" carries the same Health script, composition means each GameObject holds its own separate copy of that component's fields — the Slime's currentHP is a different variable in memory from the Knight's, so it is untouched.

Exercise 2 In the Hierarchy, Turret sits at world position (20, 0, 0) with no parent. It has one child, Barrel, whose Transform in the Inspector shows local position (0, 3, 1).
  • (a) What is Barrel's world position?
  • (b) A script on Barrel moves the Turret (its parent) to world position (20, 0, 5), and does not touch Barrel itself at all. What does Barrel.transform.localPosition print now, and what does Barrel.transform.position print now?
Show answer

(a) World position = parent's world position + child's local position = (20,0,0) + (0,3,1) = (20, 3, 1).

(b) Barrel itself was never touched, so its localPosition is still exactly (0, 3, 1) — unchanged. But its position (world space) is recomputed from the parent every time you read it, so it now reads (20,0,5) + (0,3,1) = (20, 3, 6). This is exactly why parenting is useful: moving Turret alone was enough to carry Barrel along with it.

Exercise 3 This spawner script has two separate bugs that will cause problems at runtime. Find both and explain what each one breaks.
using UnityEngine;

public class EnemySpawner : MonoBehaviour
{
    public GameObject enemyPrefab;
    public float moveSpeed = 4f;

    void Update()
    {
        transform.Translate(Vector3.right * moveSpeed);

        Rigidbody rb = GetComponent<Rigidbody>();
        rb.mass = 2f;
    }

    void SpawnOne()
    {
        Instantiate(enemyPrefab, transform.position, Quaternion.identity);
    }
}
Show answer

Bug 1 — missing Time.deltaTime: transform.Translate(Vector3.right * moveSpeed) moves the object moveSpeed units every frame, not every second. The fix is Vector3.right * moveSpeed * Time.deltaTime, so the speed stays the same regardless of frame rate.

Bug 2 — unchecked GetComponent: GetComponent<Rigidbody>() returns null if this GameObject has no Rigidbody attached, and the very next line calls .mass on it, which throws a NullReferenceException and stops Update() that frame. The fix is to check if (rb != null) before using it — or, better here, cache the Rigidbody once in Awake() instead of calling GetComponent every single frame, which is also wasteful.

(SpawnOne itself is fine as written, as long as enemyPrefab is actually assigned in the Inspector — an empty slot would make Instantiate fail the same way as bug 2, by acting on null.)

That is the core model. A GameObject is an empty container; components — starting with the Transform every object already has — give it data and behavior; a Scene is a saved tree of GameObjects; and a Prefab is a template you can edit once and reuse everywhere. Every later Unity chapter — physics, animation, UI — is just more components attached to GameObjects inside a scene, built the same way.

← Back to all chapters