Game Development with Unity and C#
Reviewed & published by Brayan K
Unity is the world's most popular game engine, and C# is the language you write your gameplay in. By the end of this lesson you'll understand how GameObjects and Components fit together, how a MonoBehaviour script comes alive through the Start()/Update() lifecycle, how to move things with Time.deltaTime, wire up the Inspector with [SerializeField], reuse objects as prefabs, drive physics with a Rigidbody, and spawn or remove objects at runtime.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- How GameObjects are built from reusable Components
- Writing a MonoBehaviour and its Start/Update lifecycle
- Frame-rate independent movement with Time.deltaTime
- Exposing fields to the Inspector with [SerializeField]
- Reusing objects as prefabs and accessing parts with GetComponent<T>()
- Physics with Rigidbody, Input, Instantiate/Destroy, and coroutines
💡 Real-World Analogy
Think of a GameObject as an empty backpack and Components as the gear you put inside it. The backpack on its own does nothing. Clip on a Transform and it has a place in the world; add a MeshRenderer and it becomes visible; add a Rigidbody and gravity grabs it; add your MonoBehaviour script and it gains behaviour. A "player", an "enemy", a "bullet" — each is just a different mix of components clipped onto a GameObject. This is composition: you build complex things by combining small, focused parts rather than by deep inheritance.
The mental model: you write components, Unity runs the loop
In a normal console app, you own Main and call everything yourself. In Unity it's inverted: the engine owns the loop, and it calls specially-named methods on your scripts at the right moments. You never call Start() or Update() yourself — you just write them, and Unity invokes them.
- 🎒 GameObject — a container that lives in the scene; means nothing until you add components.
- 🔧 Component — a piece of functionality (Transform, Rigidbody, your script) attached to a GameObject.
- 🧩 MonoBehaviour — the base class your scripts inherit from so they can be components and receive lifecycle calls.
- ⏱️ Update() — called by the engine once per frame; Start() once before the first frame.
Because the engine drives the timing, the key discipline is putting the right code in the right method: setup in Start(), per-frame logic in Update(), and physics in FixedUpdate().
📊 The MonoBehaviour Lifecycle
| Method | When it runs | Use it for |
|---|---|---|
| Awake() | Once, when object loads | Self-setup, cache references |
| Start() | Once, before first frame | Setup needing other objects |
| Update() | Every frame | Input, movement, game logic |
| FixedUpdate() | Every physics step | Rigidbody forces & velocity |
| OnDestroy() | When object is destroyed | Cleanup, unsubscribe |
Tip: never tie game speed to the frame rate. Anything that moves over time should be scaled by Time.deltaTime.
1. MonoBehaviour and the Lifecycle
A script becomes a usable component by inheriting from MonoBehaviour. Once attached to a GameObject, Unity automatically calls its lifecycle methods. Awake() fires first as the object loads, Start() runs once just before the first frame, and Update() runs every frame thereafter. You write these methods; the engine calls them. Read the worked example and note which message prints when.
using UnityEngine;
// A script becomes a COMPONENT by inheriting from MonoBehaviour.
// Attach this file to a GameObject in the scene and Unity will run
// its lifecycle methods for you — you never call them yourself.
public class PlayerController : MonoBehaviour
{
private int frameCount = 0;
// Awake() runs first, when the object loads — even if disabled.
void Awake()
{
Debug.Log("Awake: the object exists now.");
}
// Start() runs ONCE, just before the first frame this script is enabled.
// Good place for setup that depends on other objects already existing.
void Start()
{
Debug.Log("Start: ready to play!");
}
// Update() runs ONCE PER FRAME. Per-frame logic lives here:
// reading input, moving things, checking conditions.
void Update()
{
frameCount++;
if (frameCount == 1)
Debug.Log("Update: first frame rendered.");
}
}2. Movement with Time.deltaTime
If you move an object by a fixed amount every frame, it will travel faster on a fast machine and slower on a slow one. The fix is Time.deltaTime — the seconds that passed since the previous frame. Multiplying your speed by it converts "per frame" into "per second", so motion is identical regardless of frame rate. Notice the [SerializeField] on speed: it keeps the field private in code but editable in the Inspector.
using UnityEngine;
// Moves the GameObject smoothly to the right at a constant SPEED,
// no matter whether the game runs at 30, 60, or 144 fps.
public class Mover : MonoBehaviour
{
// [SerializeField] shows this private field in the Inspector so a
// designer can tune the speed without editing code.
[SerializeField] private float speed = 5f; // units per SECOND
void Update()
{
// Time.deltaTime = seconds since the previous frame.
// Multiplying by it turns "per frame" into "per second" — so the
// object covers 'speed' units every real-world second.
float distanceThisFrame = speed * Time.deltaTime;
// transform.Translate moves the Transform's position.
transform.Translate(Vector3.right * distanceThisFrame);
}
}Your turn. The mover below is almost complete — it just needs the per-frame delta and the step variable. Fill in the two ___ blanks using the hints.
using UnityEngine;
public class ForwardMover : MonoBehaviour
{
[SerializeField] private float speed = 3f; // units per second
void Update()
{
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 1) Make the movement frame-rate independent by multiplying
// speed by the seconds elapsed since the last frame.
float step = speed * Time.___; // 👉 the per-frame delta: deltaTime
// 2) Move forward (the object's blue +Z axis) by 'step'.
transform.Translate(Vector3.forward * ___); // 👉 the variable: step
// ✅ Result: the object glides forward at a steady 3 units/second
// on any machine, fast or slow.
}
}3. GetComponent and Rigidbody Physics
A script often needs to talk to another component on the same GameObject. GetComponent<T>() fetches it — for example GetComponent<Rigidbody>(). A Rigidbody hands the object over to the physics engine, so instead of setting positions directly you apply forces with AddForce and let gravity and collisions do the rest. Cache the result of GetComponent in Start() rather than calling it every frame.
using UnityEngine;
// Pushes the object upward (a "jump") using the physics engine.
// Requires a Rigidbody component on the same GameObject.
public class Jumper : MonoBehaviour
{
[SerializeField] private float jumpForce = 8f;
// Cache the Rigidbody once instead of fetching it every frame.
private Rigidbody rb;
void Start()
{
// GetComponent<T>() returns the Rigidbody attached to THIS object.
rb = GetComponent<Rigidbody>();
}
void Update()
{
// Input check belongs in Update (per frame).
if (Input.GetKeyDown(KeyCode.Space))
{
// AddForce on the Rigidbody lets the physics engine handle
// the motion — gravity will pull it back down naturally.
rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
Debug.Log("Jump!");
}
}
}4. Prefabs, Instantiate, and Destroy
A prefab is a saved GameObject template — a fully configured bullet, enemy, or coin you can stamp out as many times as you like. At runtime, Instantiate() clones a prefab into the scene and returns the new copy, while Destroy() removes a GameObject (optionally after a delay). Together they power spawning and despawning. Drag the prefab into a [SerializeField] GameObject slot in the Inspector to assign it.
Now you try. Clone the enemy prefab into the scene, then schedule it to be removed after 10 seconds. Fill in the two ___ blanks:
using UnityEngine;
public class Spawner : MonoBehaviour
{
[SerializeField] private GameObject enemyPrefab;
void SpawnEnemy()
{
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 1) Clone the prefab into the scene at this spawner's position.
GameObject enemy = ___(enemyPrefab, transform.position, Quaternion.identity); // 👉 the spawn call: Instantiate
// 2) Clean it up automatically after 10 seconds so the scene
// doesn't accumulate stale enemies.
___(enemy, 10f); // 👉 the cleanup call: Destroy
}
}5. Coroutines: Logic Across Frames
Some logic needs to wait — flash three times, fade out over a second, spawn a wave then pause. A coroutine is a method returning IEnumerator that can yield return to hand control back to Unity and resume later, all without blocking the game loop. Start one with StartCoroutine(...). The most common yield is yield return new WaitForSeconds(t).
using System.Collections;
using UnityEngine;
// A COROUTINE can pause itself and resume later across frames, without
// blocking the game. Perfect for timed sequences and waits.
public class Blinker : MonoBehaviour
{
void Start()
{
// StartCoroutine kicks off the IEnumerator method below.
StartCoroutine(BlinkRoutine());
}
// Return type IEnumerator marks this as a coroutine.
IEnumerator BlinkRoutine()
{
for (int i = 0; i < 3; i++)
{
Debug.Log("On");
// 'yield return' hands control back to Unity; the coroutine
// resumes here AFTER the wait — the game keeps running meanwhile.
yield return new WaitForSeconds(0.5f);
Debug.Log("Off");
yield return new WaitForSeconds(0.5f);
}
Debug.Log("Done blinking.");
}
}🔎 Deep Dive: Update vs FixedUpdate
Update() runs once per rendered frame, so its rate floats with the frame rate. FixedUpdate() runs on a steady physics clock (50 times a second by default), independent of frame rate. The rule of thumb: read input and do general game logic in Update(); apply physics (forces, velocity on a Rigidbody) in FixedUpdate().
// Input belongs in Update (don't miss a key between physics steps)
void Update()
{
if (Input.GetButtonDown("Jump")) wantsToJump = true;
}
// Physics belongs in FixedUpdate (consistent simulation)
void FixedUpdate()
{
if (wantsToJump) { rb.AddForce(Vector3.up * 8f, ForceMode.Impulse); wantsToJump = false; }
}Mixing these up — for example calling AddForce in Update — makes physics jittery and frame-rate dependent.
Putting It Together: a Top-Down Player
Here's a compact but realistic controller that ties the lesson together: it reads input with Input.GetAxis, builds a movement vector scaled by moveSpeed * Time.deltaTime, and drives a cached Rigidbody with MovePosition so collisions still work. The [RequireComponent] attribute guarantees a Rigidbody is present.
using UnityEngine;
// === A complete top-down player controller ===
// Combines Input, deltaTime movement, GetComponent, and a Rigidbody.
[RequireComponent(typeof(Rigidbody))]
public class TopDownPlayer : MonoBehaviour
{
[SerializeField] private float moveSpeed = 6f;
private Rigidbody rb;
void Start()
{
rb = GetComponent<Rigidbody>();
}
void Update()
{
// Read the keyboard/stick: returns -1..1 on each axis.
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
// Build a movement direction and scale by speed * deltaTime.
Vector3 move = new Vector3(h, 0f, v) * moveSpeed * Time.deltaTime;
// MovePosition respects physics/collisions better than transform.
rb.MovePosition(rb.position + move);
}
}Every idea here is something you practiced above — combined, they make a character you can actually steer around a scene.
Pro Tips
- 💡 Scale by Time.deltaTime: anything that changes over time (movement, rotation, timers) must multiply by it to stay frame-rate independent.
- 💡 Cache GetComponent in Start(): calling it every frame is wasteful — store the reference once in a field.
- 💡 Prefer [SerializeField] private over public fields: you get Inspector tuning without breaking encapsulation.
- 💡 Physics in FixedUpdate, input in Update: this keeps motion smooth and simulation stable.
- 💡 Reuse with prefabs: configure a GameObject once, save it as a prefab, and Instantiate consistent copies for bullets, enemies, and pickups.
Common Errors (and the fix)
- "NullReferenceException" on a field you forgot to assign: a [SerializeField] reference left empty in the Inspector is null at runtime. Drag the object/prefab into the slot, or assign it in Awake/Start.
- Object moves at different speeds on different PCs: you forgot Time.deltaTime. Multiply per-frame movement by it so speed is per-second.
- GetComponent returns null: the component you asked for isn't on that GameObject. Add it in the Inspector, or use [RequireComponent(typeof(...))] so Unity attaches it automatically.
- Physics feels jittery: you're moving a Rigidbody in Update or via transform.position. Move it in FixedUpdate using rb.MovePosition or forces.
- Coroutine never runs: you called the method directly (BlinkRoutine();) instead of StartCoroutine(BlinkRoutine());. A coroutine must be started by the engine.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Be a component | : MonoBehaviour | Inherit to attach to objects |
| Per-frame logic | void Update() | Runs every frame |
| Move over time | speed * Time.deltaTime | Frame-rate independent |
| Expose to Inspector | [SerializeField] private | Tune without public |
| Get a component | GetComponent<Rigidbody>() | Cache the result |
| Spawn an object | Instantiate(prefab, pos, rot) | Returns the clone |
| Remove an object | Destroy(go, 3f) | Optional delay |
| Wait across frames | yield return new WaitForSeconds(t) | Inside a coroutine |
Frequently Asked Questions
Q: Do I have to call Start() and Update() myself?
No. Unity's engine owns the game loop and calls these specially-named methods on every active MonoBehaviour. You just define them — Unity invokes them at the right time.
Q: When should I use [SerializeField] instead of public?
Use [SerializeField] private when you want a designer to tune a value in the Inspector but don't want other scripts changing it freely. It gives Inspector access while keeping the field encapsulated.
Q: What's the difference between a GameObject and a prefab?
A GameObject is a live instance in the scene. A prefab is a saved template of a configured GameObject stored as an asset, which you can Instantiate repeatedly to create identical copies.
Q: Why move with a Rigidbody instead of setting transform.position directly?
Setting the Transform teleports the object and ignores physics, which can cause objects to pass through walls. Driving a Rigidbody (forces or MovePosition) keeps collisions and the simulation consistent.
Mini-Challenge: A Steady Rotator
No blanks this time — just a brief and an outline. Make the object spin a constant 90 degrees per second around its Y axis, frame-rate independent, using transform.Rotate and Time.deltaTime. For a bonus, only rotate while the player holds the R key. Run it in your Unity project and watch the steady spin.
using UnityEngine;
public class Rotator : MonoBehaviour
{
[SerializeField] private float degreesPerSecond = 90f;
void Update()
{
// 🎯 MINI-CHALLENGE: spin this object smoothly
// 1. Compute how many degrees to rotate THIS frame:
// degreesPerSecond * Time.deltaTime (frame-rate independent).
// 2. Rotate the Transform around the Y axis (Vector3.up) by that
// amount using transform.Rotate(...).
// 3. BONUS: only spin while the player holds the R key
// (Input.GetKey(KeyCode.R)).
//
// ✅ Expected: the object turns a steady 90 degrees every second,
// identical on a slow or fast machine.
// your code here
}
}🎉 Lesson Complete
- ✅ GameObjects are built by composing Components; scripts become components via MonoBehaviour
- ✅ Unity drives the loop — Start() runs once, Update() runs every frame
- ✅ Multiply movement by Time.deltaTime to stay frame-rate independent
- ✅ [SerializeField] exposes private fields in the Inspector for tuning
- ✅ GetComponent<T>() reaches other components; a Rigidbody brings physics
- ✅ Instantiate/Destroy spawn and remove prefabs; coroutines run logic across frames
Practice quiz
What base class must a script inherit from to be attached to a GameObject as a component?
- MonoBehaviour
- GameObject
- ScriptableObject
- Component
Answer: MonoBehaviour. Scripts that attach to GameObjects inherit from MonoBehaviour, which provides the lifecycle hooks (Start, Update) and lets Unity treat the script as a component.
When is Start() called during a GameObject's lifecycle?
- Every fixed physics step
- Every frame while the object is active
- Once, before the first frame the script is enabled
- Only when the object is destroyed
Answer: Once, before the first frame the script is enabled. Start() runs exactly once, just before the first Update, after Awake. It is the place for initialization that can depend on other objects already existing.
How often is Update() called?
- Once per fixed physics timestep
- Once every frame
- Only when input is received
- Once at startup
Answer: Once every frame. Update() is called once per rendered frame, so its calling rate varies with frame rate. Per-frame logic like input polling and movement goes here.
Why do you multiply movement by Time.deltaTime in Update()?
- To convert pixels to meters
- To pause the game
- To round positions to integers
- To make movement frame-rate independent so speed is consistent
Answer: To make movement frame-rate independent so speed is consistent. Time.deltaTime is the seconds elapsed since the last frame. Multiplying by it makes movement per-second rather than per-frame, so speed stays the same across different frame rates.
Which component holds a GameObject's position, rotation, and scale?
- Transform
- Renderer
- Rigidbody
- Collider
Answer: Transform. Every GameObject has a Transform component storing its position, rotation, and scale, and defining its place in the scene hierarchy.
What does the [SerializeField] attribute do to a private field?
- It prevents the field from changing
- It exposes the private field in the Inspector while keeping it private in code
- It makes the field public to other scripts
- It saves the field to disk automatically
Answer: It exposes the private field in the Inspector while keeping it private in code. [SerializeField] lets Unity serialize and show a private field in the Inspector so designers can tweak it, without you having to make the field public.
What is a prefab in Unity?
- A compiled C# assembly
- A physics material
- A type of shader
- A reusable, saved GameObject template you can instantiate many times
Answer: A reusable, saved GameObject template you can instantiate many times. A prefab is a saved asset capturing a configured GameObject (with its components and children) so you can reuse and instantiate consistent copies, like bullets or enemies.
How do you access another component attached to the same GameObject from a script?
- Component.Load()
- new Component()
- GetComponent<T>()
- FindObjectOfType<T>() only
Answer: GetComponent<T>(). GetComponent<T>() returns the component of type T on the current GameObject, e.g. GetComponent<Rigidbody>(), so you can read or drive it from your script.
For physics-based movement, you should apply forces or set velocity on which component, ideally in FixedUpdate?
- Camera
- Rigidbody
- AudioSource
- Transform
Answer: Rigidbody. A Rigidbody makes a GameObject participate in the physics simulation. Forces and velocity changes belong on the Rigidbody and are best done in FixedUpdate, which runs on the physics clock.
Which pair of methods creates and removes GameObjects at runtime?
- Instantiate() and Destroy()
- Spawn() and Kill()
- Add() and Remove()
- Create() and Delete()
Answer: Instantiate() and Destroy(). Instantiate() clones a prefab or GameObject into the scene at runtime, and Destroy() removes a GameObject (optionally after a delay). They drive spawning bullets, enemies, and effects.
Continue this course
- Previous: Final Project — Enterprise-Grade C# Application
- Next: Desktop Apps with WPF & XAML — Create Windows desktop apps with XAML, data binding and the MVVM pattern
- Quick reference: C# cheat sheet