Back to Gaming

Unity Game Engine Series Part 2: C# Scripting Fundamentals

March 31, 2026 Wasil Zafar 45 min read

Unlock the full power of Unity by mastering C# scripting. From MonoBehaviour lifecycle methods and coroutines to the New Input System, delegates and events, LINQ, ScriptableObjects, and debugging with the Unity Profiler -- this is the programming foundation every Unity developer needs.

Table of Contents

  1. C# Basics for Unity
  2. MonoBehaviour Deep Dive
  3. Coroutines & Async Patterns
  4. Time Management
  5. Input Systems
  6. Code Architecture
  7. Debugging & Profiling
  8. Exercises & Self-Assessment
  9. C# Script Document Generator
  10. Conclusion & Next Steps

Introduction: C# and Unity -- A History

Series Overview: This is Part 2 of our 16-part Unity Game Engine Series. In this installment, we dive deep into C# scripting -- the language that breathes life into every Unity project. From basic syntax to advanced patterns, this is the programming foundation you need.

When Unity launched in 2005, it supported three scripting languages: C#, UnityScript (a JavaScript-like language), and Boo (a Python-inspired language). Over time, C# proved to be the most robust and versatile option. Boo was deprecated in Unity 5, and UnityScript was removed in Unity 2017.2, making C# the sole official scripting language for Unity.

This was the right call. C# is a statically-typed, object-oriented language developed by Microsoft's Anders Hejlsberg. It offers the safety of strong typing, the expressiveness of modern language features (generics, LINQ, async/await, pattern matching), and excellent IDE support in Visual Studio and JetBrains Rider. Unity currently supports C# 9.0 features with .NET Standard 2.1 compatibility.

Key Insight: C# in Unity is not standard .NET desktop development. Unity uses its own runtime (formerly Mono, now transitioning to CoreCLR), has specific garbage collection behavior, and provides the MonoBehaviour base class that hooks into the engine's game loop. Understanding these differences is crucial for writing performant game code.

C# Evolution in Unity

Unity Version C# Version Key Features Added
Unity 1.0-4.x C# 3.0 LINQ, lambda expressions, var keyword
Unity 5.x C# 4.0 Dynamic keyword, named/optional parameters
Unity 2017 C# 6.0 String interpolation ($""), null-conditional (?.), expression-bodied members
Unity 2019 C# 7.3 Tuples, pattern matching, local functions, ref returns
Unity 2021+ C# 9.0 Records, init-only setters, top-level statements, improved pattern matching
Case Study

Celeste -- Input Handling Done Right

Matt Thorson and Noel Berry's Celeste is widely regarded as having some of the tightest, most responsive controls in platforming history. The secret lies in their input buffering system: when a player presses jump slightly before landing, the game "remembers" the input for a few frames and executes it when valid. This is implemented through carefully timed C# coroutines and frame-accurate input state tracking. The game also uses "coyote time" -- a brief window after walking off a ledge where the player can still jump -- implemented as a simple float timer decremented in Update(). These tiny C# details are the difference between controls that feel "sluggish" and controls that feel "perfect."

Input Buffering Coyote Time Frame-Accurate Responsive Controls

1. C# Basics for Unity

1.1 Classes, Methods & Properties

Every Unity script is a class that typically inherits from MonoBehaviour. Understanding C# class structure is foundational:

using UnityEngine;
using System.Collections.Generic;

// A well-structured Unity component class
public class PlayerHealth : MonoBehaviour
{
    // === SERIALIZED FIELDS (visible in Inspector) ===
    [Header("Health Configuration")]
    [SerializeField] private float maxHealth = 100f;
    [SerializeField, Range(0f, 5f)] private float regenRate = 1f;
    [SerializeField, Tooltip("Time in seconds before health regeneration begins")]
    private float regenDelay = 3f;

    // === PROPERTIES (encapsulated access) ===
    public float CurrentHealth { get; private set; }
    public float HealthPercent => CurrentHealth / maxHealth;
    public bool IsAlive => CurrentHealth > 0;
    public bool IsFullHealth => CurrentHealth >= maxHealth;

    // === EVENTS (notify other systems) ===
    public event System.Action<float> OnHealthChanged;      // float = new percent
    public event System.Action OnDeath;
    public event System.Action OnRevive;

    // === PRIVATE STATE ===
    private float lastDamageTime;
    private bool isDead = false;

    // === LIFECYCLE METHODS ===
    private void Awake()
    {
        CurrentHealth = maxHealth;
    }

    private void Update()
    {
        // Passive health regeneration after delay
        if (IsAlive && !IsFullHealth && Time.time - lastDamageTime > regenDelay)
        {
            Heal(regenRate * Time.deltaTime);
        }
    }

    // === PUBLIC METHODS ===
    public void TakeDamage(float amount)
    {
        if (isDead || amount <= 0) return;

        CurrentHealth = Mathf.Max(0, CurrentHealth - amount);
        lastDamageTime = Time.time;
        OnHealthChanged?.Invoke(HealthPercent);

        if (CurrentHealth <= 0)
        {
            isDead = true;
            OnDeath?.Invoke();
        }
    }

    public void Heal(float amount)
    {
        if (isDead || amount <= 0) return;

        CurrentHealth = Mathf.Min(maxHealth, CurrentHealth + amount);
        OnHealthChanged?.Invoke(HealthPercent);
    }

    public void Revive(float healthPercent = 0.5f)
    {
        if (!isDead) return;

        isDead = false;
        CurrentHealth = maxHealth * Mathf.Clamp01(healthPercent);
        OnHealthChanged?.Invoke(HealthPercent);
        OnRevive?.Invoke();
    }
}

1.2 Lists, Dictionaries & Collections

Game development is all about managing groups of objects -- enemies in a wave, items in an inventory, waypoints on a path. C# collections are essential:

Collection Use Case Access Time Unity Example
List<T> Ordered, dynamic-size collection O(1) index, O(n) search Active enemies, inventory slots
Dictionary<K,V> Key-value lookup O(1) lookup Item database by ID, pooled objects by tag
Queue<T> First-in, first-out O(1) enqueue/dequeue Spawn queue, command buffer
Stack<T> Last-in, first-out O(1) push/pop Undo system, menu navigation history
HashSet<T> Unique items, fast contains check O(1) contains Visited rooms, unlocked achievements
using UnityEngine;
using System.Collections.Generic;
using System.Linq;

public class InventorySystem : MonoBehaviour
{
    // Dictionary for O(1) lookup by item ID
    private Dictionary<string, InventoryItem> itemDatabase = new();

    // List for ordered inventory display
    private List<InventorySlot> slots = new();

    // HashSet for tracking unique discoveries
    private HashSet<string> discoveredItems = new();

    // Queue for pickup notifications
    private Queue<string> notificationQueue = new();

    public void AddItem(string itemId, int quantity = 1)
    {
        if (!itemDatabase.TryGetValue(itemId, out var item))
        {
            Debug.LogWarning($"Item {itemId} not found in database!");
            return;
        }

        // Track discovery
        if (discoveredItems.Add(itemId))
        {
            notificationQueue.Enqueue($"New discovery: {item.DisplayName}!");
        }

        // Find existing slot or create new one
        var existingSlot = slots.FirstOrDefault(s => s.ItemId == itemId && !s.IsFull);
        if (existingSlot != null)
        {
            existingSlot.Quantity += quantity;
        }
        else
        {
            slots.Add(new InventorySlot { ItemId = itemId, Quantity = quantity });
        }
    }

    // LINQ: Find the 5 rarest items the player has discovered
    public List<InventoryItem> GetRarestItems(int count = 5)
    {
        return discoveredItems
            .Select(id => itemDatabase[id])
            .OrderByDescending(item => item.Rarity)
            .Take(count)
            .ToList();
    }
}

1.3 Delegates, Events & Generics

Delegates are type-safe function pointers -- they allow you to pass methods as parameters, store them, and invoke them later. Events build on delegates to create the Observer pattern, which is fundamental to decoupled game architecture:

using UnityEngine;
using System;

// === DELEGATES: Type-safe function references ===
public delegate float DamageCalculation(float baseDamage, float armor);

// === EVENT-DRIVEN GAME MANAGER ===
public class GameEventSystem : MonoBehaviour
{
    // Singleton for global access
    public static GameEventSystem Instance { get; private set; }

    // Events using System.Action (built-in delegates)
    public event Action OnGameStarted;
    public event Action OnGamePaused;
    public event Action OnGameResumed;
    public event Action<int> OnScoreChanged;           // int = new score
    public event Action<string, int> OnAchievementUnlocked; // name, points

    // Custom delegate for complex events
    public delegate void DamageEvent(GameObject source, GameObject target, float amount);
    public event DamageEvent OnDamageDealt;

    private void Awake()
    {
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        Instance = this;
        DontDestroyOnLoad(gameObject);
    }

    // Fire-and-forget event invocation
    public void TriggerDamage(GameObject source, GameObject target, float amount)
    {
        OnDamageDealt?.Invoke(source, target, amount);
    }

    public void AddScore(int points)
    {
        OnScoreChanged?.Invoke(points);
    }
}

// === SUBSCRIBER: Any system can listen without coupling ===
public class DamageNumberUI : MonoBehaviour
{
    private void OnEnable()
    {
        // Subscribe to damage events
        GameEventSystem.Instance.OnDamageDealt += ShowDamageNumber;
    }

    private void OnDisable()
    {
        // Always unsubscribe to prevent memory leaks!
        if (GameEventSystem.Instance != null)
            GameEventSystem.Instance.OnDamageDealt -= ShowDamageNumber;
    }

    private void ShowDamageNumber(GameObject source, GameObject target, float amount)
    {
        // Spawn floating damage text at target position
        Debug.Log($"{source.name} dealt {amount} damage to {target.name}");
    }
}

// === GENERICS: Reusable object pool ===
public class ObjectPool<T> where T : MonoBehaviour
{
    private Queue<T> pool = new();
    private T prefab;
    private Transform parent;

    public ObjectPool(T prefab, int initialSize, Transform parent = null)
    {
        this.prefab = prefab;
        this.parent = parent;

        for (int i = 0; i < initialSize; i++)
        {
            T obj = UnityEngine.Object.Instantiate(prefab, parent);
            obj.gameObject.SetActive(false);
            pool.Enqueue(obj);
        }
    }

    public T Get()
    {
        T obj = pool.Count > 0 ? pool.Dequeue() : UnityEngine.Object.Instantiate(prefab, parent);
        obj.gameObject.SetActive(true);
        return obj;
    }

    public void Return(T obj)
    {
        obj.gameObject.SetActive(false);
        pool.Enqueue(obj);
    }
}
Pro Tip: Always unsubscribe from events in OnDisable() or OnDestroy(). Forgotten subscriptions are the #1 cause of memory leaks and "ghost" behavior in Unity projects where destroyed objects still receive event callbacks.

2. MonoBehaviour Deep Dive

2.1 Serialized Fields & Attributes

Unity's Inspector displays and edits serialized fields -- data that gets saved with the scene or prefab. Understanding serialization attributes is key to building designer-friendly components:

Attribute Purpose Example
[SerializeField] Expose private field to Inspector [SerializeField] private float speed;
[Header("...")] Add a section header in Inspector [Header("Movement Settings")]
[Range(min, max)] Clamp value with slider UI [Range(0f, 20f)] private float speed;
[Tooltip("...")] Hover text in Inspector [Tooltip("Units per second")]
[Space(px)] Add vertical space in Inspector [Space(10)]
[TextArea(min,max)] Multi-line text input [TextArea(3, 8)] private string description;
[HideInInspector] Hide public field from Inspector [HideInInspector] public int internalId;
using UnityEngine;

public class EnemyAI : MonoBehaviour
{
    [Header("Detection")]
    [SerializeField, Tooltip("How far the enemy can see the player")]
    private float detectionRange = 15f;
    [SerializeField, Range(30f, 360f)] private float fieldOfView = 120f;
    [SerializeField] private LayerMask detectionLayers;

    [Header("Combat")]
    [SerializeField, Range(1f, 100f)] private float attackDamage = 10f;
    [SerializeField] private float attackCooldown = 1.5f;
    [SerializeField] private float attackRange = 2f;

    [Header("Movement")]
    [SerializeField] private float patrolSpeed = 3f;
    [SerializeField] private float chaseSpeed = 6f;
    [SerializeField] private Transform[] patrolWaypoints;

    [Header("Debug")]
    [SerializeField] private bool showGizmos = true;
    [SerializeField] private Color detectionColor = Color.yellow;
    [SerializeField] private Color attackColor = Color.red;

    // Visualize detection ranges in Scene View
    private void OnDrawGizmosSelected()
    {
        if (!showGizmos) return;

        // Detection range
        Gizmos.color = detectionColor;
        Gizmos.DrawWireSphere(transform.position, detectionRange);

        // Attack range
        Gizmos.color = attackColor;
        Gizmos.DrawWireSphere(transform.position, attackRange);

        // FOV cone visualization
        Vector3 leftDir = Quaternion.Euler(0, -fieldOfView / 2, 0) * transform.forward;
        Vector3 rightDir = Quaternion.Euler(0, fieldOfView / 2, 0) * transform.forward;
        Gizmos.color = Color.cyan;
        Gizmos.DrawRay(transform.position, leftDir * detectionRange);
        Gizmos.DrawRay(transform.position, rightDir * detectionRange);
    }
}

2.2 RequireComponent & Dependencies

The [RequireComponent] attribute ensures that dependent components are always present on the GameObject. This prevents null reference errors from missing dependencies:

using UnityEngine;

// This ensures a Rigidbody and AudioSource always exist on this GameObject
[RequireComponent(typeof(Rigidbody))]
[RequireComponent(typeof(AudioSource))]
public class PlayerController : MonoBehaviour
{
    private Rigidbody rb;
    private AudioSource audioSource;

    [SerializeField] private AudioClip jumpSound;
    [SerializeField] private float jumpForce = 8f;

    private void Awake()
    {
        // Safe to call -- RequireComponent guarantees these exist
        rb = GetComponent<Rigidbody>();
        audioSource = GetComponent<AudioSource>();
    }

    public void Jump()
    {
        rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
        audioSource.PlayOneShot(jumpSound);
    }
}

// [DisallowMultipleComponent] prevents duplicate scripts
[DisallowMultipleComponent]
public class GameManager : MonoBehaviour
{
    // Only one GameManager can exist on any GameObject
}
Common Mistake: Avoid calling GetComponent<T>() in Update(). Each call searches through the GameObject's component list. Cache the reference in Awake() instead. In a game with 1,000 enemies each calling GetComponent 60 times per second, that is 60,000 unnecessary lookups per second.

3. Coroutines & Async Patterns

Coroutines allow you to spread work across multiple frames, create timed sequences, and build complex asynchronous behaviors -- all without multithreading. They are one of Unity's most powerful features.

3.1 IEnumerator & Yield Return

A coroutine is a method that returns IEnumerator and uses yield return statements to pause execution and resume on a later frame:

Yield Instruction Resumes When Use Case
yield return null Next frame's Update() Per-frame processing, animations
yield return new WaitForSeconds(t) After t seconds (scaled time) Delays, cooldowns, spawn timers
yield return new WaitForSecondsRealtime(t) After t real seconds (ignores timeScale) Pause menus, UI animations
yield return new WaitForFixedUpdate() Next FixedUpdate() Physics-aligned timing
yield return new WaitForEndOfFrame() After rendering completes Screenshot capture, post-render effects
yield return new WaitUntil(() => condition) When condition becomes true Waiting for player input, loading state
yield return new WaitWhile(() => condition) When condition becomes false Waiting for animation to finish
yield return StartCoroutine(other) When the other coroutine completes Chaining sequential operations
using UnityEngine;
using System.Collections;

public class WaveSpawner : MonoBehaviour
{
    [SerializeField] private GameObject[] enemyPrefabs;
    [SerializeField] private Transform[] spawnPoints;
    [SerializeField] private float timeBetweenWaves = 10f;
    [SerializeField] private float timeBetweenSpawns = 0.5f;
    [SerializeField] private int enemiesPerWave = 5;

    private int currentWave = 0;
    private int enemiesAlive = 0;

    private IEnumerator Start()
    {
        // Wait 3 seconds before the first wave
        yield return new WaitForSeconds(3f);

        // Infinite wave loop
        while (true)
        {
            currentWave++;
            Debug.Log($"--- Wave {currentWave} ---");

            // Spawn the wave
            yield return StartCoroutine(SpawnWave(currentWave));

            // Wait until all enemies are defeated
            yield return new WaitUntil(() => enemiesAlive <= 0);

            Debug.Log($"Wave {currentWave} cleared!");

            // Countdown to next wave
            yield return StartCoroutine(WaveCountdown(timeBetweenWaves));
        }
    }

    private IEnumerator SpawnWave(int waveNumber)
    {
        int enemiesToSpawn = enemiesPerWave + (waveNumber - 1) * 2;

        for (int i = 0; i < enemiesToSpawn; i++)
        {
            SpawnEnemy();
            yield return new WaitForSeconds(timeBetweenSpawns);
        }
    }

    private IEnumerator WaveCountdown(float duration)
    {
        float remaining = duration;
        while (remaining > 0)
        {
            Debug.Log($"Next wave in {remaining:F0}s");
            yield return new WaitForSeconds(1f);
            remaining -= 1f;
        }
    }

    private void SpawnEnemy()
    {
        GameObject prefab = enemyPrefabs[Random.Range(0, enemyPrefabs.Length)];
        Transform point = spawnPoints[Random.Range(0, spawnPoints.Length)];
        Instantiate(prefab, point.position, point.rotation);
        enemiesAlive++;
    }

    public void OnEnemyDied() => enemiesAlive--;
}

3.2 Common Coroutine Patterns

Case Study

Ori and the Blind Forest -- Coroutine-Driven Sequences

Moon Studios' Ori and the Blind Forest uses coroutine-heavy architecture for its cinematic sequences and ability unlocks. When Ori learns a new ability, the game triggers a carefully choreographed sequence: time slows, the camera zooms in, particle effects play, the ability icon appears, and the UI animates -- all orchestrated through chained coroutines. Each step waits for the previous one to complete, creating a seamless narrative moment without interrupting gameplay flow. This pattern -- sequential coroutine chaining -- is a workhorse of game feel polish.

Coroutine Chaining Cinematic Sequences Game Feel
using UnityEngine;
using System.Collections;

public class CoroutinePatterns : MonoBehaviour
{
    // Pattern 1: Smooth Lerp (move/rotate/fade over time)
    public IEnumerator SmoothMove(Transform target, Vector3 destination, float duration)
    {
        Vector3 startPos = target.position;
        float elapsed = 0f;

        while (elapsed < duration)
        {
            elapsed += Time.deltaTime;
            float t = Mathf.SmoothStep(0f, 1f, elapsed / duration);
            target.position = Vector3.Lerp(startPos, destination, t);
            yield return null;
        }

        target.position = destination; // Snap to exact position
    }

    // Pattern 2: Flashing (damage feedback)
    public IEnumerator FlashRed(SpriteRenderer renderer, int flashes = 3)
    {
        Color original = renderer.color;
        for (int i = 0; i < flashes; i++)
        {
            renderer.color = Color.red;
            yield return new WaitForSeconds(0.1f);
            renderer.color = original;
            yield return new WaitForSeconds(0.1f);
        }
    }

    // Pattern 3: Typewriter text effect
    public IEnumerator TypewriterEffect(TMPro.TextMeshProUGUI textUI, string fullText, float charDelay = 0.03f)
    {
        textUI.text = "";
        foreach (char c in fullText)
        {
            textUI.text += c;
            yield return new WaitForSeconds(charDelay);
        }
    }

    // Pattern 4: Screen shake
    public IEnumerator ScreenShake(Transform cameraTransform, float duration = 0.3f, float magnitude = 0.1f)
    {
        Vector3 originalPos = cameraTransform.localPosition;
        float elapsed = 0f;

        while (elapsed < duration)
        {
            float x = Random.Range(-1f, 1f) * magnitude;
            float y = Random.Range(-1f, 1f) * magnitude;
            cameraTransform.localPosition = originalPos + new Vector3(x, y, 0);

            elapsed += Time.deltaTime;
            magnitude = Mathf.Lerp(magnitude, 0f, elapsed / duration); // Decay
            yield return null;
        }

        cameraTransform.localPosition = originalPos;
    }
}

4. Time Management

4.1 deltaTime, fixedDeltaTime & timeScale

Time is the heartbeat of your game. Understanding Unity's time values is critical for frame-rate independent behavior:

Property Value Use In Purpose
Time.deltaTime Varies (e.g., 0.016 at 60fps) Update() Frame-rate independent movement
Time.fixedDeltaTime Default 0.02 (50fps) FixedUpdate() Physics timestep
Time.timeScale Default 1.0 Anywhere Slow-mo (0.5), pause (0), fast-forward (2)
Time.unscaledDeltaTime Ignores timeScale Update() Pause menu animations, UI during slow-mo
Time.time Seconds since game start Anywhere Timestamps, cooldown tracking
Time.frameCount Total frames rendered Anywhere Frame-based timing, debugging
using UnityEngine;

public class TimeManagement : MonoBehaviour
{
    [Header("Slow Motion")]
    [SerializeField, Range(0.01f, 1f)] private float slowMoScale = 0.3f;
    [SerializeField] private float slowMoDuration = 2f;

    // WRONG: Not frame-rate independent
    // transform.Translate(Vector3.forward * speed);
    // At 60fps: moves 60 * speed per second
    // At 30fps: moves 30 * speed per second (half as fast!)

    // CORRECT: Frame-rate independent
    // transform.Translate(Vector3.forward * speed * Time.deltaTime);
    // At 60fps: moves 60 * speed * 0.0167 = speed per second
    // At 30fps: moves 30 * speed * 0.0333 = speed per second (same!)

    public void TriggerSlowMotion()
    {
        StartCoroutine(SlowMotionRoutine());
    }

    private System.Collections.IEnumerator SlowMotionRoutine()
    {
        // Enter slow motion
        Time.timeScale = slowMoScale;
        Time.fixedDeltaTime = 0.02f * Time.timeScale; // Scale physics too

        // Wait in REAL time (ignores timeScale)
        yield return new WaitForSecondsRealtime(slowMoDuration);

        // Smoothly return to normal speed
        float elapsed = 0f;
        float returnDuration = 0.5f;
        while (elapsed < returnDuration)
        {
            elapsed += Time.unscaledDeltaTime;
            float t = elapsed / returnDuration;
            Time.timeScale = Mathf.Lerp(slowMoScale, 1f, t);
            Time.fixedDeltaTime = 0.02f * Time.timeScale;
            yield return null;
        }

        Time.timeScale = 1f;
        Time.fixedDeltaTime = 0.02f;
    }
}
Critical Reminder: When setting Time.timeScale, always also update Time.fixedDeltaTime to 0.02f * Time.timeScale. Otherwise, physics will run at the normal rate while everything else is slowed down, causing jittery physics and desynchronized gameplay.

5. Input Systems

5.1 Legacy Input Manager

Unity's original input system is simple and still widely used in tutorials and smaller projects:

using UnityEngine;

public class LegacyInputExample : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private float jumpForce = 8f;
    private Rigidbody rb;

    private void Awake() => rb = GetComponent<Rigidbody>();

    private void Update()
    {
        // Axis input (-1 to 1, with smoothing)
        float horizontal = Input.GetAxis("Horizontal"); // A/D or Left/Right
        float vertical = Input.GetAxis("Vertical");     // W/S or Up/Down

        // Raw input (no smoothing, instant -1/0/1)
        float rawH = Input.GetAxisRaw("Horizontal");

        // Movement
        Vector3 move = new Vector3(horizontal, 0, vertical).normalized;
        transform.Translate(move * moveSpeed * Time.deltaTime);

        // Button input
        if (Input.GetButtonDown("Jump"))   // Space bar (pressed this frame)
            rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);

        if (Input.GetButton("Fire1"))      // Left mouse (held down)
            Debug.Log("Firing!");

        if (Input.GetButtonUp("Fire1"))    // Left mouse (released this frame)
            Debug.Log("Stopped firing");

        // Key input (specific keys)
        if (Input.GetKeyDown(KeyCode.Escape))
            TogglePause();

        // Mouse input
        float mouseX = Input.GetAxis("Mouse X");
        float mouseY = Input.GetAxis("Mouse Y");
        Vector3 mousePos = Input.mousePosition; // Screen coordinates
    }

    private void TogglePause()
    {
        Time.timeScale = Time.timeScale == 0 ? 1 : 0;
    }
}

5.2 New Input System

Unity's New Input System (package: com.unity.inputsystem) is a modern, action-based alternative that supports multiple devices, rebinding, and composite inputs. It separates what the player wants to do (actions) from how they do it (bindings):

Feature Legacy Input New Input System
Configuration Edit > Project Settings > Input Manager .inputactions asset file (visual editor)
Device Support Keyboard, mouse, joystick basics Keyboard, mouse, gamepad, touch, XR, custom
Rebinding Manual implementation required Built-in runtime rebinding API
Action Maps Not supported Switch contexts (Gameplay, UI, Vehicle)
Multi-device Difficult, manual per-device code Automatic -- one action, multiple bindings
Local Multiplayer Manual player management PlayerInput component with auto-split
using UnityEngine;
using UnityEngine.InputSystem;

// Approach 1: Using PlayerInput component (most Unity-like)
[RequireComponent(typeof(PlayerInput))]
public class NewInputPlayer : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private float jumpForce = 8f;

    private Rigidbody rb;
    private Vector2 moveInput;
    private bool jumpPressed;

    private void Awake() => rb = GetComponent<Rigidbody>();

    // Called by PlayerInput component via SendMessages or UnityEvents
    public void OnMove(InputValue value)
    {
        moveInput = value.Get<Vector2>();
    }

    public void OnJump(InputValue value)
    {
        jumpPressed = value.isPressed;
    }

    private void FixedUpdate()
    {
        // Apply movement
        Vector3 move = new Vector3(moveInput.x, 0, moveInput.y);
        rb.MovePosition(rb.position + move * moveSpeed * Time.fixedDeltaTime);

        if (jumpPressed)
        {
            rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
            jumpPressed = false;
        }
    }
}

// Approach 2: Direct InputAction references (more control)
public class DirectInputPlayer : MonoBehaviour
{
    [SerializeField] private InputActionAsset inputActions;
    private InputAction moveAction;
    private InputAction jumpAction;
    private InputAction fireAction;

    private void Awake()
    {
        var gameplayMap = inputActions.FindActionMap("Gameplay");
        moveAction = gameplayMap.FindAction("Move");
        jumpAction = gameplayMap.FindAction("Jump");
        fireAction = gameplayMap.FindAction("Fire");
    }

    private void OnEnable()
    {
        moveAction.Enable();
        jumpAction.Enable();
        fireAction.Enable();

        // Subscribe to action callbacks
        jumpAction.performed += OnJumpPerformed;
        fireAction.started += OnFireStarted;
        fireAction.canceled += OnFireCanceled;
    }

    private void OnDisable()
    {
        jumpAction.performed -= OnJumpPerformed;
        fireAction.started -= OnFireStarted;
        fireAction.canceled -= OnFireCanceled;

        moveAction.Disable();
        jumpAction.Disable();
        fireAction.Disable();
    }

    private void Update()
    {
        // Read move value every frame
        Vector2 move = moveAction.ReadValue<Vector2>();
        transform.Translate(new Vector3(move.x, 0, move.y) * 5f * Time.deltaTime);
    }

    private void OnJumpPerformed(InputAction.CallbackContext ctx)
    {
        Debug.Log("Jump!");
    }

    private void OnFireStarted(InputAction.CallbackContext ctx) => Debug.Log("Fire started");
    private void OnFireCanceled(InputAction.CallbackContext ctx) => Debug.Log("Fire stopped");
}

6. Code Architecture

6.1 MVC/MVVM for Games

As your game grows beyond a prototype, you need architecture. Without it, you end up with "spaghetti code" where everything depends on everything, and changing one system breaks three others. Game development borrows patterns from software engineering, adapted for real-time interactive systems:

Pattern Separation Game Example
MVC (Model-View-Controller) Data / Display / Logic PlayerData (model), HealthBar (view), PlayerController (controller)
MVVM (Model-View-ViewModel) Data / Display / Data-binding layer InventoryData, InventoryUI, InventoryViewModel (syncs data to UI)
Service Locator Central registry for services ServiceLocator.Get<IAudioManager>() -- loosely coupled access
Observer (Events) Publishers and subscribers HealthChanged event -- UI, audio, VFX all react independently
using UnityEngine;

// === MODEL: Pure data, no Unity dependencies ===
[System.Serializable]
public class PlayerData
{
    public string PlayerName;
    public int Level;
    public int Experience;
    public int MaxExperience;
    public float Health;
    public float MaxHealth;

    public float HealthPercent => Health / MaxHealth;
    public float ExpPercent => (float)Experience / MaxExperience;

    public void GainExperience(int amount)
    {
        Experience += amount;
        while (Experience >= MaxExperience)
        {
            Experience -= MaxExperience;
            Level++;
            MaxExperience = Mathf.RoundToInt(MaxExperience * 1.5f);
        }
    }
}

// === VIEW: Only handles display, knows nothing about game logic ===
public class PlayerHUD : MonoBehaviour
{
    [SerializeField] private UnityEngine.UI.Slider healthBar;
    [SerializeField] private UnityEngine.UI.Slider expBar;
    [SerializeField] private TMPro.TextMeshProUGUI levelText;
    [SerializeField] private TMPro.TextMeshProUGUI nameText;

    public void UpdateHealth(float percent)
    {
        healthBar.value = percent;
    }

    public void UpdateExperience(float percent, int level)
    {
        expBar.value = percent;
        levelText.text = $"Lv. {level}";
    }

    public void SetPlayerName(string name)
    {
        nameText.text = name;
    }
}

// === CONTROLLER: Connects Model and View, handles game logic ===
public class PlayerManager : MonoBehaviour
{
    [SerializeField] private PlayerHUD hud;
    private PlayerData data;

    private void Start()
    {
        data = new PlayerData
        {
            PlayerName = "Hero",
            Level = 1,
            Experience = 0,
            MaxExperience = 100,
            Health = 100,
            MaxHealth = 100
        };

        hud.SetPlayerName(data.PlayerName);
        RefreshHUD();
    }

    public void TakeDamage(float amount)
    {
        data.Health = Mathf.Max(0, data.Health - amount);
        hud.UpdateHealth(data.HealthPercent);
    }

    public void GainExp(int amount)
    {
        data.GainExperience(amount);
        RefreshHUD();
    }

    private void RefreshHUD()
    {
        hud.UpdateHealth(data.HealthPercent);
        hud.UpdateExperience(data.ExpPercent, data.Level);
    }
}

6.2 ScriptableObjects for Data-Driven Design

ScriptableObjects are data containers that live as assets in your project. They are one of the most powerful and underused features in Unity, enabling data-driven design where designers can tweak game balance without touching code:

using UnityEngine;

// ScriptableObject: Lives as an asset, shared across scenes
[CreateAssetMenu(fileName = "NewWeapon", menuName = "Game/Weapon Data")]
public class WeaponData : ScriptableObject
{
    [Header("Identity")]
    public string weaponName;
    [TextArea(2, 4)] public string description;
    public Sprite icon;

    [Header("Stats")]
    public float damage = 10f;
    public float attackSpeed = 1f;      // Attacks per second
    public float range = 2f;
    public DamageType damageType;

    [Header("Visual/Audio")]
    public GameObject hitEffectPrefab;
    public AudioClip attackSound;
    public AudioClip hitSound;

    [Header("Progression")]
    public int unlockLevel = 1;
    public int purchaseCost = 100;
}

public enum DamageType { Physical, Fire, Ice, Lightning, Poison }

// Usage in a weapon system
public class WeaponSystem : MonoBehaviour
{
    [SerializeField] private WeaponData currentWeapon;
    private float lastAttackTime;

    public void Attack(Transform target)
    {
        if (Time.time - lastAttackTime < 1f / currentWeapon.attackSpeed)
            return; // Still on cooldown

        float distance = Vector3.Distance(transform.position, target.position);
        if (distance > currentWeapon.range)
            return; // Out of range

        // Deal damage
        var health = target.GetComponent<PlayerHealth>();
        health?.TakeDamage(currentWeapon.damage);

        // Spawn hit effect
        if (currentWeapon.hitEffectPrefab != null)
            Instantiate(currentWeapon.hitEffectPrefab, target.position, Quaternion.identity);

        lastAttackTime = Time.time;
    }

    public void EquipWeapon(WeaponData newWeapon)
    {
        currentWeapon = newWeapon;
        Debug.Log($"Equipped {newWeapon.weaponName} ({newWeapon.damageType})");
    }
}
Design Power: With ScriptableObjects, a game designer can create 50 different weapons by right-clicking in the Project window and filling in values -- zero coding required. Need to rebalance the fire sword? Open the asset and change the damage value. Changes take effect immediately in Play Mode. This is the foundation of professional game production pipelines.

7. Debugging & Profiling

7.1 Debug.Log, DrawRay & Breakpoints

Debugging in Unity goes far beyond print() statements. Master these tools to diagnose issues in seconds instead of hours:

Tool Method Best For
Debug.Log Debug.Log("msg", gameObject) General logging (click to highlight source object)
Debug.LogWarning Debug.LogWarning("msg") Non-critical issues, potential problems
Debug.LogError Debug.LogError("msg") Critical errors that need immediate attention
Debug.DrawRay Debug.DrawRay(origin, dir, color) Visualizing raycasts, directions, line of sight
Debug.DrawLine Debug.DrawLine(start, end, color) Visualizing connections, paths, distances
Gizmos OnDrawGizmos() / OnDrawGizmosSelected() Persistent Scene View visualization (ranges, areas)
Breakpoints Visual Studio / Rider debugger Step-by-step execution, variable inspection
using UnityEngine;

public class DebugTechniques : MonoBehaviour
{
    // Conditional compilation: Debug.Log calls are stripped in release builds
    [System.Diagnostics.Conditional("UNITY_EDITOR")]
    private void DebugLogEditor(string message)
    {
        Debug.Log($"[{gameObject.name}] {message}", gameObject);
    }

    // Rich text in console
    private void LogFormatted()
    {
        Debug.Log("<color=red><b>CRITICAL:</b></color> Player health below 10%!");
        Debug.Log($"<color=cyan>Frame {Time.frameCount}</color>: Position = {transform.position}");
    }

    // Visual debugging in Scene View
    private void Update()
    {
        // Draw forward direction (green ray)
        Debug.DrawRay(transform.position, transform.forward * 5f, Color.green);

        // Draw velocity vector (if has Rigidbody)
        var rb = GetComponent<Rigidbody>();
        if (rb != null)
            Debug.DrawRay(transform.position, rb.velocity, Color.red);
    }

    // Persistent gizmos visible in Scene View
    private void OnDrawGizmos()
    {
        // Always visible
        Gizmos.color = new Color(1, 0, 0, 0.3f);
        Gizmos.DrawSphere(transform.position, 0.5f);
    }

    private void OnDrawGizmosSelected()
    {
        // Only when selected
        Gizmos.color = Color.yellow;
        Gizmos.DrawWireSphere(transform.position, 10f);
    }
}

7.2 Unity Profiler: CPU, GPU & Memory

The Unity Profiler (Window > Analysis > Profiler) is your performance microscope. It shows exactly where your game spends its time every frame:

Profiler Module What It Shows Key Metrics
CPU Usage Time spent per system (scripts, physics, rendering, GC) Frame time, GC alloc, function call counts
GPU Usage Rendering performance Draw calls, batches, SetPass calls, shader time
Memory RAM usage by category Total used, textures, meshes, audio, managed heap
Physics PhysX performance Active bodies, contacts, collider counts
Audio Audio system load Playing sources, DSP load, memory
Performance Killer: The #1 performance problem in Unity scripts is garbage collection (GC) allocation in Update(). Every frame, if your Update() creates new strings, arrays, or objects with new, the garbage collector must eventually clean them up, causing frame-rate hitches. Common culprits: string concatenation in Update, FindObjectOfType() every frame, creating new Lists/arrays instead of reusing them, and LINQ queries in hot paths. Profile first, optimize where the data shows problems.
using UnityEngine;
using Unity.Profiling;

public class ProfilingExample : MonoBehaviour
{
    // Custom profiler markers for your own code
    static readonly ProfilerMarker s_UpdateMarker = new("MyGame.Update");
    static readonly ProfilerMarker s_AIMarker = new("MyGame.AICalculation");

    private void Update()
    {
        // Wrap code sections with profiler markers
        using (s_UpdateMarker.Auto())
        {
            ProcessInput();

            using (s_AIMarker.Auto())
            {
                UpdateAI();
            }
        }
    }

    // BAD: Creates garbage every frame
    private void BadUpdate()
    {
        string status = "Player at " + transform.position.ToString(); // GC alloc!
        var enemies = FindObjectsOfType<EnemyAI>(); // GC alloc + slow!
        var nearby = new List<Transform>(); // GC alloc!
    }

    // GOOD: Zero allocations
    private List<Transform> nearbyCache = new(); // Reuse
    private void GoodUpdate()
    {
        // Use cached references, avoid string creation, reuse collections
        nearbyCache.Clear();
        // ... fill from pre-cached enemy list
    }

    private void ProcessInput() { /* ... */ }
    private void UpdateAI() { /* ... */ }
}

Exercises & Self-Assessment

Exercise 1

Event-Driven Score System

Build a complete event-driven scoring system:

  1. Create a ScoreManager singleton with events: OnScoreChanged, OnHighScoreBeaten, OnComboIncreased
  2. Create a ScoreUI that subscribes to these events and updates TextMeshPro text
  3. Create a ComboSystem that tracks consecutive hits within a time window (1.5s) and multiplies score
  4. Create an EnemyScore component that fires score events when its health reaches zero
  5. Test with 10 enemies -- verify that the UI updates, combos work, and no null reference errors occur when enemies are destroyed
Exercise 2

Coroutine Challenge: Ability Cooldown System

Implement a 3-ability hotbar with cooldowns using coroutines:

  1. Create a WeaponData ScriptableObject with: name, damage, cooldown time, icon, and effect prefab
  2. Create 3 weapon assets with different stats (Fireball: 50 dmg / 3s, Ice Shard: 20 dmg / 0.5s, Lightning: 100 dmg / 8s)
  3. Build an AbilitySystem that uses coroutines for cooldown tracking
  4. Show cooldown progress on UI (filling radial overlay using Image.fillAmount)
  5. Implement the "Celeste" input buffer: if the player presses an ability key during cooldown, queue it and activate when ready
Exercise 3

New Input System Setup

Convert a legacy input project to the New Input System:

  1. Install the Input System package via Package Manager
  2. Create an Input Actions asset with two Action Maps: "Gameplay" (Move, Jump, Fire, Interact) and "UI" (Navigate, Submit, Cancel)
  3. Bind each action to both keyboard/mouse AND gamepad
  4. Add a PlayerInput component and wire actions to your player script
  5. Implement action map switching: press Escape to switch from Gameplay to UI map and back
  6. Test with both keyboard and gamepad -- both should work without any code changes
Exercise 4

Reflective Questions

  1. Why should you cache GetComponent results in Awake() instead of calling it in Update()? What is the actual performance cost?
  2. Explain the difference between WaitForSeconds and WaitForSecondsRealtime. When would you use each?
  3. A ScriptableObject asset is shared across all instances. If you modify a field at runtime, what happens to other objects referencing it? How do you prevent unintended shared-state bugs?
  4. Compare the Observer pattern (C# events) with Unity's SendMessage(). Why do experienced developers prefer events?
  5. Your game runs at 60fps on PC but 25fps on mobile. Which Unity Profiler modules would you check first, and what common problems would you look for?

C# Script Document Generator

Generate a professional script documentation template for your Unity C# scripts. Download as Word, Excel, PDF, or PowerPoint.

Draft auto-saved

All data stays in your browser. Nothing is sent to or stored on any server.

Conclusion & Next Steps

You now have a strong foundation in C# scripting for Unity. Here are the key takeaways from Part 2:

  • C# is Unity's sole scripting language -- master its features (generics, LINQ, delegates, events) to write cleaner, more maintainable game code
  • MonoBehaviour attributes ([SerializeField], [Header], [Range], [Tooltip]) make your components designer-friendly and self-documenting
  • Coroutines are essential for timed sequences, cooldowns, wave spawners, and polish effects like screen shake and typewriter text
  • Time.deltaTime makes movement frame-rate independent -- always multiply by it in Update(). Use fixedDeltaTime in FixedUpdate() for physics
  • The New Input System separates actions from bindings, supporting multiple devices, rebinding, and action maps out of the box
  • ScriptableObjects enable data-driven design where designers can create and tune content without touching code
  • The Unity Profiler is your performance microscope -- profile first, optimize where the data shows problems, and avoid GC allocations in Update()

Next in the Series

In Part 3: GameObjects & Components, we'll explore the Transform system (local vs world space, Quaternions), core components (renderers, cameras, lights), writing custom reusable components, scene organization, and advanced composition patterns including event-driven communication.

Gaming