
Learn C# for Unity from beginner to advanced in 2026 — MonoBehaviour lifecycle, coroutines, async, performance and DOTS. Start building games today.
If you want to build games in 2026, learning C# for Unity is still the single highest-leverage skill you can pick up. Unity ships with C# as its only first-class scripting language, and the version of C# you write inside the editor today is a modern, fast, fully-featured language — not the cut-down dialect people remember from a decade ago. This guide walks you from your very first MonoBehaviour to advanced topics like the Job System, object pooling and allocation-free game loops, with runnable code you can paste straight into a project.
Why C# for Unity Is Different From "Normal" C#
Everything you know about .NET still applies — LINQ, generics, async, pattern matching, records. But Unity adds a layer on top that trips up almost every newcomer, and understanding it early will save you weeks.
- You don't own the entry point. There is no
Main(). Unity's engine loop calls your code through magic methods on classes derived fromMonoBehaviour. - The main thread is sacred. Almost all Unity API calls (transforms, physics, UI, instantiation) must happen on the main thread. Touch a
Transformfrom a background task and you'll get an exception, not a race condition. - Garbage collection is visible to players. A 20 ms GC pause is invisible in a web API and a dropped frame in a 60 FPS game.
- Unity objects have "fake null". A destroyed
GameObjectis not trulynullin CLR terms, which quietly breaks?.and??. More on this below — it's the #1 subtle bug in Unity codebases.
Beginner: Your First Unity C# Script
Every behaviour you attach to a GameObject inherits from MonoBehaviour. Here's a complete, working player controller that demonstrates the core patterns.
using UnityEngine;
[RequireComponent(typeof(Rigidbody))]
public class PlayerController : MonoBehaviour
{
[SerializeField] private float moveSpeed = 6f;
[SerializeField] private float jumpForce = 5f;
[SerializeField] private LayerMask groundMask;
private Rigidbody _rigidbody;
private Vector3 _input;
private bool _jumpQueued;
private void Awake()
{
// Cache once. Never call GetComponent in Update.
_rigidbody = GetComponent<Rigidbody>();
}
private void Update()
{
// Input is read every rendered frame.
_input = new Vector3(
Input.GetAxisRaw("Horizontal"),
0f,
Input.GetAxisRaw("Vertical")).normalized;
if (Input.GetButtonDown("Jump") && IsGrounded())
_jumpQueued = true;
}
private void FixedUpdate()
{
// Physics is applied on the fixed timestep.
Vector3 velocity = _input * moveSpeed;
velocity.y = _rigidbody.linearVelocity.y;
_rigidbody.linearVelocity = velocity;
if (_jumpQueued)
{
_rigidbody.AddForce(Vector3.up * jumpForce, ForceMode.VelocityChange);
_jumpQueued = false;
}
}
private bool IsGrounded() =>
Physics.Raycast(transform.position, Vector3.down, 1.1f, groundMask);
}
Three beginner lessons are baked into that script:
1. [SerializeField] private beats public. You get the Inspector field without leaking a mutable public API to every other class in your game. Use public only when another script genuinely needs access.
2. Input in Update, physics in FixedUpdate. Update runs once per rendered frame (variable rate); FixedUpdate runs on a fixed timestep (0.02 s by default). Reading GetButtonDown in FixedUpdate will miss presses, because a button-down is true for exactly one frame. Queue the intent in Update, consume it in FixedUpdate.
3. Cache your components. GetComponent is a native lookup. Calling it 60 times a second per enemy across 200 enemies is measurable waste.
The MonoBehaviour Lifecycle You Must Memorise
Awake()— once, when the object loads. Set up your own references here.OnEnable()— every time the object is enabled. Subscribe to events here.Start()— once, before the first frame, after allAwakecalls. Reference other objects here (they're guaranteed initialised).FixedUpdate()— fixed timestep, physics.Update()— per frame, gameplay and input.LateUpdate()— after allUpdatecalls. Camera follow belongs here, otherwise the camera lags one frame behind the player.OnDisable()/OnDestroy()— unsubscribe from events here. Forgetting this is the most common source of Unity memory leaks.
The Awake/Start split is not stylistic. If two objects both grab each other in Awake, execution order decides whether it works — a classic flaky bug. Initialise self in Awake, wire up others in Start.
Intermediate: Data, Events and Time
Use ScriptableObjects for Configuration
Hard-coding stats into MonoBehaviours forces designers through code and bloats prefabs. A ScriptableObject is a plain data asset that lives in your project, shared by reference.
using UnityEngine;
[CreateAssetMenu(fileName = "WeaponData", menuName = "Game/Weapon Data")]
public class WeaponData : ScriptableObject
{
public string displayName = "Pistol";
public int damage = 10;
public float fireRate = 0.2f;
public GameObject projectilePrefab;
}
Now 500 pistol instances share one 200-byte asset instead of duplicating stats into every prefab. Be aware of one trap: edits to a ScriptableObject at runtime persist in the editor but not in a build. Treat them as read-only defaults, and copy mutable state into a runtime class.
Events: Decouple Your Systems
using System;
using UnityEngine;
public class Health : MonoBehaviour
{
public event Action<int, int> HealthChanged; // current, max
public event Action Died;
[SerializeField] private int maxHealth = 100;
private int _current;
private void Awake() => _current = maxHealth;
public void TakeDamage(int amount)
{
if (_current <= 0) return;
_current = Mathf.Max(0, _current - amount);
HealthChanged?.Invoke(_current, maxHealth);
if (_current == 0)
Died?.Invoke();
}
}
public class HealthBarUI : MonoBehaviour
{
[SerializeField] private Health health;
[SerializeField] private UnityEngine.UI.Slider slider;
private void OnEnable() => health.HealthChanged += OnHealthChanged;
private void OnDisable() => health.HealthChanged -= OnHealthChanged;
private void OnHealthChanged(int current, int max) =>
slider.value = (float)current / max;
}
The symmetric OnEnable/OnDisable subscription is the correct Unity idiom. A C# event holds a strong reference to the subscriber; if the UI is destroyed while still subscribed, you get a leak and a MissingReferenceException the next time the event fires.
Always Multiply by Time.deltaTime
// WRONG — speed depends on the player's frame rate
transform.position += Vector3.forward * 5f;
// RIGHT — 5 units per second on any hardware
transform.position += Vector3.forward * 5f * Time.deltaTime;
Skip this and your game literally plays faster on a 240 Hz monitor than a 60 Hz one. In FixedUpdate, use Time.fixedDeltaTime.
Coroutines vs async/await in 2026
Coroutines are Unity's classic cooperative multitasking primitive — they run on the main thread and are tied to the lifetime of the MonoBehaviour.
private IEnumerator FlashRoutine()
{
for (int i = 0; i < 3; i++)
{
_renderer.enabled = false;
yield return new WaitForSeconds(0.1f);
_renderer.enabled = true;
yield return new WaitForSeconds(0.1f);
}
}
// StartCoroutine(FlashRoutine());
Modern Unity adds Awaitable, which brings the same main-thread safety to async/await — with real return values, exception propagation and cancellation.
using System.Threading;
using UnityEngine;
public class Flasher : MonoBehaviour
{
[SerializeField] private Renderer _renderer;
private readonly CancellationTokenSource _cts = new();
private async void Start()
{
try { await FlashAsync(_cts.Token); }
catch (System.OperationCanceledException) { /* object destroyed */ }
}
private async Awaitable FlashAsync(CancellationToken token)
{
for (int i = 0; i < 3; i++)
{
_renderer.enabled = false;
await Awaitable.WaitForSecondsAsync(0.1f, token);
_renderer.enabled = true;
await Awaitable.WaitForSecondsAsync(0.1f, token);
}
}
private void OnDestroy()
{
_cts.Cancel();
_cts.Dispose();
}
}
Rule of thumb: coroutines for short, frame-bound visual sequences; async/Awaitable for anything that returns a value, loads content, or talks to the network. Critically, async void methods are not cancelled when the GameObject is destroyed — always pass a cancellation token you cancel in OnDestroy.
The Fake Null Trap
Unity overloads == on UnityEngine.Object so that a destroyed object compares equal to null. The C# null-propagating operators do not use that overload — they check the raw reference.
// DANGEROUS on UnityEngine.Object: bypasses Unity's == overload.
// If 'target' was destroyed, this does NOT short-circuit.
target?.DoSomething();
// SAFE
if (target != null)
target.DoSomething();
Use ?. freely on plain C# classes and on event invocation, but never on a GameObject, Component or ScriptableObject reference.
Advanced C# for Unity: Performance
At this level, C# for Unity becomes mostly about not allocating. Unity's default garbage collector is incremental, but the cheapest collection is the one that never happens.
Object Pooling Instead of Instantiate/Destroy
using UnityEngine;
using UnityEngine.Pool;
public class BulletSpawner : MonoBehaviour
{
[SerializeField] private Bullet bulletPrefab;
private ObjectPool<Bullet> _pool;
private void Awake()
{
_pool = new ObjectPool<Bullet>(
createFunc: () => Instantiate(bulletPrefab),
actionOnGet: b => b.gameObject.SetActive(true),
actionOnRelease: b => b.gameObject.SetActive(false),
actionOnDestroy: b => Destroy(b.gameObject),
defaultCapacity: 64,
maxSize: 256);
}
public void Fire(Vector3 origin, Vector3 direction)
{
Bullet bullet = _pool.Get();
bullet.transform.SetPositionAndRotation(origin, Quaternion.LookRotation(direction));
bullet.Launch(direction, onExpired: () => _pool.Release(bullet));
}
}
Instantiate and Destroy are among the most expensive calls in the engine. A bullet-hell game that pools its projectiles can be an order of magnitude faster than one that doesn't.
Allocation Hotspots to Eliminate
- LINQ in
Update.enemies.Where(e => e.IsAlive).First()allocates an iterator and a closure every frame. Write the loop. - String concatenation.
"Score: " + scoreallocates. Cache the UI text and only update it when the score actually changes. foreachover an interface or non-generic collection boxes the enumerator.List<T>has a struct enumerator and is fine.- Physics queries. Use
Physics.RaycastNonAlloc/OverlapSphereNonAllocwith a reusable array instead of the allocating overloads. GameObject.FindandSendMessage. String-based, slow, and refactor-hostile. Use serialized references or a proper service locator.- Unnecessary
Updatemethods. An emptyUpdatestill costs a native-to-managed call. Delete it, or disable components that have nothing to do.
Burst and the Job System
For genuinely heavy workloads — flow fields, thousands of agents, procedural meshes — move work off the main thread with the C# Job System and compile it with Burst.
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
[BurstCompile]
public struct MoveJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float3> Velocities;
public NativeArray<float3> Positions;
public float DeltaTime;
public void Execute(int index) =>
Positions[index] += Velocities[index] * DeltaTime;
}
// Usage:
// var handle = new MoveJob { Positions = positions, Velocities = velocities,
// DeltaTime = Time.deltaTime }.Schedule(count, 64);
// handle.Complete();
Jobs must be structs using only unmanaged types and NativeArray-style collections — no GameObject, no class, no managed strings. That restriction is exactly what lets Burst emit SIMD machine code that routinely runs 10–50× faster than the equivalent Mono code. Always Dispose() your native collections; the leak detector will warn you, but only in the editor.
Best Practices Checklist
- Cache component references in
Awake; neverGetComponentper frame. - Subscribe in
OnEnable, unsubscribe inOnDisable— always paired. - Multiply movement by
Time.deltaTime; do physics inFixedUpdate. - Prefer
[SerializeField] privateoverpublicfields. - Never use
?.on Unity objects. - Pool anything you spawn more than a handful of times.
- Profile before optimising — use the Unity Profiler and Profile Analyzer on a real device, not the editor.
- Keep gameplay logic in plain C# classes where possible; they're testable without the engine.
Conclusion: Your Path Forward
Mastering C# for Unity is a progression, not a single leap. Start by internalising the MonoBehaviour lifecycle and the Update/FixedUpdate split — that alone eliminates most beginner bugs. Move on to ScriptableObjects and C# events so your systems stop knowing about each other. Then, when the profiler tells you to, graduate to pooling, allocation-free loops and finally the Job System with Burst.
Key takeaways: cache aggressively, allocate reluctantly, unsubscribe religiously, and never trust ?. on a Unity object. Build one small, finished game applying these patterns — a top-down shooter with pooled bullets and ScriptableObject weapon data will exercise nearly everything in this guide. Ship it, profile it, and the advanced material will stop feeling abstract and start feeling necessary.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment