Unity uses C# for its scripting, but the way you write it differs from ordinary C#. You rarely create objects with new, there is no Main method, and the engine calls your code through named lifecycle methods. This guide covers those differences, the frame-rate trap that catches everyone, and the performance mistakes that matter most.
The shape of a Unity script#
using UnityEngine;
public class PlayerController : MonoBehaviour
{
[SerializeField] private float speed = 5f;
[SerializeField] private float jumpForce = 7f;
private Rigidbody2D _body;
private bool _grounded;
private void Awake()
{
_body = GetComponent<Rigidbody2D>();
}
private void Update()
{
float horizontal = Input.GetAxisRaw("Horizontal");
transform.Translate(Vector3.right * horizontal * speed * Time.deltaTime);
if (_grounded && Input.GetButtonDown("Jump"))
{
_body.AddForce(Vector2.up * jumpForce, ForceMode2D.Impulse);
_grounded = false;
}
}
}
Two things differ from ordinary C#. The class inherits MonoBehaviour, which is what lets Unity attach it to a GameObject and call its methods. And [SerializeField] exposes a private field in the Inspector, so designers can tune values without editing code.
The lifecycle methods#
| Method | When it runs | Use for |
|---|---|---|
Awake |
once, before everything | getting your own components |
OnEnable |
each time the object is enabled | subscribing to events |
Start |
once, before the first frame | referencing other objects |
Update |
every frame | input, most game logic |
FixedUpdate |
at a fixed rate | physics forces |
LateUpdate |
after all Updates | cameras following a target |
OnDisable |
when disabled | unsubscribing |
The Awake and Start split matters. Awake runs on every object before any Start, so setting up your own references in Awake and reaching for other objects in Start avoids depending on which order objects happened to initialise in.
deltaTime, the frame-rate trap#
// Wrong - speed depends on frame rate
transform.position += Vector3.right * 5f;
// Right - metres per second, whatever the frame rate
transform.position += Vector3.right * 5f * Time.deltaTime;
Update runs once per frame, and frames are not evenly spaced. Without Time.deltaTime, your character moves twice as fast on a 120 Hz monitor as on a 60 Hz one — the classic symptom being a game that is unplayable on a faster machine.
Multiply anything per-frame by Time.deltaTime: movement, rotation, timers, gradual changes.
Update or FixedUpdate#
private void Update()
{
// Input, every frame, or you miss button presses
_jumpQueued |= Input.GetButtonDown("Jump");
}
private void FixedUpdate()
{
// Physics forces, at a fixed rate
if (_jumpQueued)
{
_body.AddForce(Vector2.up * jumpForce, ForceMode2D.Impulse);
_jumpQueued = false;
}
}
FixedUpdate runs on a fixed timestep independent of frame rate, which is what the physics engine needs to be deterministic. Read input in Update, because GetButtonDown is only true for one frame and a FixedUpdate may skip it entirely.
Getting references#
private void Awake()
{
_body = GetComponent<Rigidbody2D>(); // on this object
_sprite = GetComponentInChildren<SpriteRenderer>();
}
// Never in Update - both are expensive
private void Update()
{
GameObject.Find("Player").transform.position = ...; // searches every object
GetComponent<Rigidbody2D>().AddForce(...); // repeated lookup
}
Cache references in Awake or Start and reuse them. Better still, assign them in the Inspector with [SerializeField], which costs nothing at run time and makes the dependency visible.
Prefabs and instantiation#
public class Spawner : MonoBehaviour
{
[SerializeField] private GameObject bulletPrefab;
[SerializeField] private Transform muzzle;
public void Fire()
{
GameObject bullet = Instantiate(bulletPrefab, muzzle.position, muzzle.rotation);
Destroy(bullet, 3f); // clean up after three seconds
}
}
A prefab is a saved GameObject with its components and values. Instantiate creates a copy at run time.
Creating and destroying objects constantly causes garbage collection pauses. For anything frequent — bullets, particles, enemies — reuse instead:
public class BulletPool : MonoBehaviour
{
[SerializeField] private GameObject prefab;
[SerializeField] private int size = 30;
private readonly Queue<GameObject> _available = new();
private void Awake()
{
for (int i = 0; i < size; i++)
{
var obj = Instantiate(prefab, transform);
obj.SetActive(false);
_available.Enqueue(obj);
}
}
public GameObject Take(Vector3 position, Quaternion rotation)
{
var obj = _available.Count > 0 ? _available.Dequeue() : Instantiate(prefab, transform);
obj.transform.SetPositionAndRotation(position, rotation);
obj.SetActive(true);
return obj;
}
public void Return(GameObject obj)
{
obj.SetActive(false);
_available.Enqueue(obj);
}
}
Coroutines#
private IEnumerator FadeOut(float duration)
{
float elapsed = 0f;
var colour = _sprite.color;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
colour.a = Mathf.Lerp(1f, 0f, elapsed / duration);
_sprite.color = colour;
yield return null; // wait for the next frame
}
gameObject.SetActive(false);
}
// Start it
StartCoroutine(FadeOut(1.5f));
A coroutine spreads work across frames without blocking. yield return null waits one frame; yield return new WaitForSeconds(2f) waits two seconds. Note that the second allocates, so avoid it inside a tight loop — cache the wait object instead.
The five mistakes#
- Forgetting
Time.deltaTime. The game plays differently on different hardware. GetComponentorFindin Update. Cache the reference once.- Moving a Rigidbody with
transform.position. It bypasses physics and causes objects to pass through walls. UseMovePositionor forces. - Comparing tags with
gameObject.tag == "Enemy". That allocates a string every call; useCompareTag("Enemy"). - Instantiate and Destroy every frame. Pool instead.
Ordinary C# still applies#
// A plain class, not a MonoBehaviour - no engine overhead
[Serializable]
public class InventoryItem
{
public string Name;
public int Quantity;
}
// ScriptableObject for shared data assets
[CreateAssetMenu(menuName = "Game/Weapon")]
public class WeaponData : ScriptableObject
{
public float damage;
public float fireRate;
}
Not everything needs to be a MonoBehaviour. Data, rules and calculations belong in plain C# classes, which are lighter and can be unit tested without starting the engine.
Questions people ask#
Do I need to know C# before Unity?
Basic syntax, variables, conditions, loops and classes — yes. You can learn the rest as you go, but starting with zero programming makes Unity considerably harder than it needs to be.
Why is there no Main method?
Unity owns the program loop. Your scripts are components it calls at defined points, which is why the lifecycle methods matter so much.
Does Unity C# differ from normal C#?
It is the same language. The differences are the engine API, the lifecycle model, and some older Unity versions supporting older C# language versions.
Can I unit test Unity code?
Yes — the Test Framework package supports both edit-mode and play-mode tests. Code in plain C# classes is far easier to test than code inside MonoBehaviours, which is a good reason to keep logic out of them.
Where to go next#
- C# basics — the language itself.
- Object-oriented programming in C# — classes and components.
- C# performance optimization — allocation, which matters a great deal here.