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.
1
Unity Basics & Interface
Editor overview, assets, prefabs, architecture
2
C# Scripting Fundamentals
MonoBehaviour, coroutines, input systems, patterns
You Are Here
3
GameObjects & Components
Transforms, renderers, custom components
4
Physics & Collisions
Rigidbody, colliders, raycasting, forces
5
UI Systems
Canvas, uGUI, UI Toolkit, responsive design
6
Animation & State Machines
Animator, blend trees, IK, Timeline
7
Audio & Visual Effects
AudioSource, particles, VFX Graph, post-processing
8
Building & Publishing
Build pipeline, optimization, platforms, monetization
9
Rendering Pipelines
URP, HDRP, Shader Graph, lighting systems
10
Data-Oriented Tech Stack
ECS, Jobs System, Burst Compiler
11
AI & Gameplay Systems
NavMesh, FSMs, behavior trees, procedural gen
12
Multiplayer & Networking
Netcode, RPCs, latency, prediction
13
Tools & Editor Scripting
Custom editors, debug tools, CI/CD
14
Architecture & Clean Code
Service locators, DI, ScriptableObject architecture
15
Performance Optimization
CPU/GPU profiling, memory, object pooling
16
Production & Industry Practices
Git, Agile, asset pipelines, debugging at scale
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
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;
}
}
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
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:
- Create a
ScoreManager singleton with events: OnScoreChanged, OnHighScoreBeaten, OnComboIncreased
- Create a
ScoreUI that subscribes to these events and updates TextMeshPro text
- Create a
ComboSystem that tracks consecutive hits within a time window (1.5s) and multiplies score
- Create an
EnemyScore component that fires score events when its health reaches zero
- 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:
- Create a
WeaponData ScriptableObject with: name, damage, cooldown time, icon, and effect prefab
- Create 3 weapon assets with different stats (Fireball: 50 dmg / 3s, Ice Shard: 20 dmg / 0.5s, Lightning: 100 dmg / 8s)
- Build an
AbilitySystem that uses coroutines for cooldown tracking
- Show cooldown progress on UI (filling radial overlay using
Image.fillAmount)
- 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:
- Install the Input System package via Package Manager
- Create an Input Actions asset with two Action Maps: "Gameplay" (Move, Jump, Fire, Interact) and "UI" (Navigate, Submit, Cancel)
- Bind each action to both keyboard/mouse AND gamepad
- Add a
PlayerInput component and wire actions to your player script
- Implement action map switching: press Escape to switch from Gameplay to UI map and back
- Test with both keyboard and gamepad -- both should work without any code changes
Exercise 4
Reflective Questions
- Why should you cache
GetComponent results in Awake() instead of calling it in Update()? What is the actual performance cost?
- Explain the difference between
WaitForSeconds and WaitForSecondsRealtime. When would you use each?
- 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?
- Compare the Observer pattern (C# events) with Unity's
SendMessage(). Why do experienced developers prefer events?
- 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?
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.
Continue the Series
Part 1: Unity Basics & Interface
Master the Unity editor, asset pipeline, prefab system, and component-based architecture that powers modern game development.
Read Article
Part 3: GameObjects & Components
Deep dive into transforms, renderers, custom components, scene organization, and advanced composition patterns.
Read Article
Part 4: Physics & Collisions
Rigidbodies, colliders, raycasting, forces, joints, and building physics-driven gameplay.
Read Article