| name | unity-csharp-guide |
| description | Unity C# code generation best practices. Use when writing C# for Unity (MonoBehaviour, ScriptableObject, serialization, async/await, IL2CPP, hot-path allocation) to avoid common AI mistakes and version-gated API misuse. |
Unity C# Practice Guide
Best practices for AI when generating C# for Unity.
Unity is not plain .NET: the engine owns object lifetime, the main thread, serialization, and the AOT compiler. Most AI mistakes come from applying standard .NET patterns that the engine silently breaks.
Each #if UNITY_..._OR_NEWER guard marks an API that only exists from that version. When the target version is unknown, prefer the guarded (newer) API and keep the guard.
MonoBehaviour lifetime
Unity constructs, serializes, and destroys MonoBehaviour/ScriptableObject for you. Never use new, and never rely on constructors.
var enemy = new EnemyController();
var enemy = gameObject.AddComponent<EnemyController>();
var config = ScriptableObject.CreateInstance<EnemyConfig>();
Constructors run on Unity's loading thread before serialization, so parameters and field access there are meaningless.
public sealed class Turret : MonoBehaviour
{
public Turret(int damage) { _damage = damage; }
int _damage;
}
public sealed class Turret : MonoBehaviour
{
[SerializeField] int _damage;
public void Init(int damage) => _damage = damage;
}
async void Start() { await LoadAsync(); }
#if UNITY_2023_1_OR_NEWER
async Awaitable Start() { await LoadAsync(); }
#else
async void Start()
{
try { await LoadAsync(); }
catch (Exception e) { Debug.LogException(e); }
}
#endif
async void swallows exceptions: they escape to the SynchronizationContext instead of the caller, so a failed Start/OnEnable fails silently. Only use async void for genuine event handlers that must return void, and even then wrap the body in try/catch.
Serialization
The Inspector serializes fields, not properties. Expose state as [SerializeField] private fields, not public fields, so other code cannot mutate them freely.
public float speed = 5f;
[SerializeField, Tooltip("Units per second")] float _speed = 5f;
public float Speed => _speed;
[field: SerializeField] public float Health { get; private set; }
Use const for values that never change and [SerializeField] for values a designer tunes. Document non-obvious ranges with [Tooltip] / [Range] rather than a comment.
const float GravityScale = 9.81f;
[SerializeField, Range(0f, 1f)] float _drag;
async/await on the Unity main thread
Unity's API is single-threaded. Blocking the main thread on a Task deadlocks the editor and player.
var data = LoadAsync().Result;
LoadAsync().Wait();
var data = await LoadAsync();
Pick the return type by where the continuation must run:
async Task<byte[]> ReadFileAsync(string path) =>
await File.ReadAllBytesAsync(path);
#if UNITY_2023_1_OR_NEWER
async Awaitable SpawnAsync()
{
await Awaitable.WaitForSecondsAsync(1f);
transform.position = Vector3.zero;
}
#endif
Cancellation: catch OperationCanceledException before the general catch, otherwise a normal cancel is logged as an error.
async Awaitable RunAsync(CancellationToken token)
{
try
{
await StepAsync(token);
}
catch (OperationCanceledException) { }
catch (Exception e) { Debug.LogException(e); }
}
#if UNITY_2022_2_OR_NEWER
await StepAsync(destroyCancellationToken);
#endif
IL2CPP (AOT) constraints
IL2CPP compiles ahead-of-time and strips unused code. Runtime code generation and reflection-only members break.
var dm = new DynamicMethod(...);
Anything reached only via reflection (JSON binding, DI, SendMessage) can be stripped. Mark it to survive:
using UnityEngine.Scripting;
[Preserve]
public sealed class SaveData { public int Score; }
For third-party assemblies you cannot annotate, add a link.xml at an Assets root:
<linker>
<assembly fullname="Newtonsoft.Json" preserve="all"/>
</linker>
Version-gated API migration
Prefer the newer API and keep the #if guard so the code still compiles on older editors.
#if UNITY_2022_2_OR_NEWER
var all = Object.FindObjectsByType<Enemy>(FindObjectsSortMode.None);
var one = Object.FindFirstObjectByType<Enemy>();
#else
var all = Object.FindObjectsOfType<Enemy>();
var one = Object.FindObjectOfType<Enemy>();
#endif
#if UNITY_2021_1_OR_NEWER
using UnityEngine.Pool;
readonly ObjectPool<Bullet> _pool = new(() => Instantiate(prefab));
#endif
#if UNITY_2021_2_OR_NEWER
Span<int> tail = buffer[^4..];
#endif
#if UNITY_6000_4_OR_NEWER
EntityId id = gameObject.GetEntityId();
#else
int id = gameObject.GetInstanceID();
#endif
#if UNITY_2023_1_OR_NEWER
async Awaitable BlinkAsync()
{
await Awaitable.NextFrameAsync();
}
#else
IEnumerator Blink() { yield return null; }
#endif
Hot-path allocation traps
Update, FixedUpdate, and LateUpdate run every frame. Allocations there cause GC spikes.
void Update() { GetComponent<Rigidbody>().AddForce(_force); }
Rigidbody _rb;
void Awake() => _rb = GetComponent<Rigidbody>();
void Update() => _rb.AddForce(_force);
void Update() { var near = enemies.Where(e => e.Alive).ToList(); }
void Update()
{
for (int i = 0; i < enemies.Count; i++)
if (enemies[i].Alive) { }
}
void Update() { _label.text = "Score: " + score + " / " + max; }
readonly StringBuilder _sb = new(32);
void SetLabel()
{
_sb.Clear();
_sb.Append("Score: ").Append(score).Append(" / ").Append(max);
_label.SetText(_sb);
}
void Update() { Schedule(() => Handle(currentTarget)); }
Iterating a plain List<T> with foreach is fine (its enumerator is a struct). On older Unity (pre-2020) foreach over interface-typed or non-List collections boxed the enumerator — prefer a for loop there.
Event function rules
Execution order within one object is guaranteed; order across objects is not.
- All
Awake() run before any Start(). Do cross-component wiring in Start, not Awake, if it depends on another object's Awake having finished.
- Physics goes in
FixedUpdate (fixed timestep); input and per-frame logic go in Update.
- Camera-follow and anything reading a transform moved this frame goes in
LateUpdate.
- Never assume
Enemy.Awake runs before Spawner.Awake — set explicit references or use Script Execution Order.
void OnGUI() { }
#if UNITY_EDITOR
void OnGUI() { GUILayout.Label(_debugInfo); }
#endif
Naming and file structure
- One public class per file; the file name must match the class name exactly (
Turret.cs → class Turret). MonoBehaviours require this or the component won't bind.
- Namespace mirrors the directory path under
Assets / the assembly root.
- Abstract base: no prefix/suffix (
Weapon); concrete types are suffixed with the base name (MeleeWeapon, RangedWeapon).
- Enum: singular PascalCase (
enum WeaponKind). [Flags] enum: plural (enum DamageTypes), values as powers of two.
"Why not" comment pattern
When you tried a standard approach and rejected it for a Unity-specific reason, record why not so the next agent doesn't rediscover the dead end. Keep it to the mechanical reason.
Rigidbody _rb;
async Awaitable Start() { ... }
Only add these where the standard choice looks correct but fails — not on ordinary code.