Skip to main content

Appendix A - Best C# Practices For Unity Game Developers

Introduction

C# is the primary programming language used in Unity game development. While general C# best practices apply to Unity development, there are specific considerations and patterns that are particularly important in game development contexts. This guide provides Unity-specific C# best practices to help you write efficient, maintainable, and performant game code.


Unity-Specific C# Coding Style

Unity Coding Conventions

  1. Unity's Recommended Practices:

    • Follow Unity's recommended coding practices, which build upon standard C# conventions but include game-specific considerations. For detailed guidance, visit Unity Scripting Best Practices.
  2. MonoBehaviour Naming and Organization:

    • Component Naming: Name MonoBehaviour scripts after their functionality (e.g., PlayerController, EnemySpawner).
    • Method Order: Organize Unity lifecycle methods in chronological order (Awake, Start, Update, etc.).
    • Serialized Fields: Use [SerializeField] for inspector-exposed private fields rather than making fields public.
    public class PlayerController : MonoBehaviour
    {
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private float jumpForce = 10f;

    private Rigidbody2D rb;
    private bool isGrounded;

    private void Awake()
    {
    rb = GetComponent<Rigidbody2D>();
    }

    private void Start()
    {
    // Initialization code
    }

    private void Update()
    {
    HandleInput();
    }

    private void FixedUpdate()
    {
    HandleMovement();
    }

    private void HandleInput()
    {
    // Input processing logic
    }

    private void HandleMovement()
    {
    // Movement implementation
    }
    }

Unity-Specific Naming Conventions

  1. Script Naming:

    • Name scripts to match their class names and functionality (e.g., PlayerController.cs contains the PlayerController class).
    • Use PascalCase for both file names and class names.
  2. Unity-Specific Naming Patterns:

    • Prefix interfaces with "I" (e.g., IDamageable).
    • Use descriptive suffixes for scriptable objects (e.g., WeaponData, EnemyConfig).
    • Name event handlers with "On" prefix (e.g., OnPlayerDeath, OnLevelComplete).
  3. Inspector-Friendly Naming:

    • Use spaces in serialized field names with the [DisplayName] attribute for better inspector readability.
    [SerializeField]
    [DisplayName("Maximum Health")]
    private float maxHealth = 100f;

Unity Performance Optimization

Memory Management in Unity

  1. Garbage Collection Awareness:

    • Minimize garbage generation, especially in frequently called methods like Update().
    • Cache references to components and avoid GetComponent<T>() calls in update loops.
    // Bad practice - generates garbage every frame
    private void Update()
    {
    Vector3 direction = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
    transform.Translate(direction * moveSpeed * Time.deltaTime);
    }

    // Good practice - reuses the vector
    private Vector3 _direction;

    private void Update()
    {
    _direction.x = Input.GetAxis("Horizontal");
    _direction.y = 0;
    _direction.z = Input.GetAxis("Vertical");

    transform.Translate(_direction * moveSpeed * Time.deltaTime);
    }
  2. Object Pooling:

    • Implement object pooling for frequently instantiated and destroyed objects like projectiles, particles, or enemies.
    public class ObjectPool : MonoBehaviour
    {
    [SerializeField] private GameObject prefab;
    [SerializeField] private int poolSize = 20;

    private List<GameObject> pool;

    private void Awake()
    {
    pool = new List<GameObject>(poolSize);

    for (int i = 0; i < poolSize; i++)
    {
    GameObject obj = Instantiate(prefab);
    obj.SetActive(false);
    pool.Add(obj);
    }
    }

    public GameObject GetPooledObject()
    {
    for (int i = 0; i < pool.Count; i++)
    {
    if (!pool[i].activeInHierarchy)
    {
    return pool[i];
    }
    }

    // If no inactive objects, return null or expand pool
    return null;
    }
    }

Efficient Unity Scripting

  1. Update Method Optimization:

    • Use the appropriate update method for each task:
      • Update(): For input handling and regular game logic.
      • FixedUpdate(): For physics calculations.
      • LateUpdate(): For camera follow and final position adjustments.
      • Coroutines: For time-based operations that don't need to run every frame.
  2. Coroutines vs. Update:

    • Use coroutines for operations that occur over time but don't need to be checked every frame.
    // Instead of checking in Update
    private float cooldownTimer = 0f;
    private float cooldownDuration = 2f;

    private void Update()
    {
    if (cooldownTimer > 0)
    {
    cooldownTimer -= Time.deltaTime;
    }
    }

    // Use a coroutine
    private IEnumerator CooldownCoroutine()
    {
    yield return new WaitForSeconds(cooldownDuration);
    // Cooldown complete
    }
  3. Physics Optimization:

    • Use non-alloc physics methods to reduce garbage generation.
    • Implement physics layers and collision matrices to limit unnecessary collision checks.
    // Preallocate array to avoid garbage
    private RaycastHit[] hits = new RaycastHit[10];

    private void CheckForEnemies()
    {
    int hitCount = Physics.SphereCastNonAlloc(
    transform.position,
    detectionRadius,
    transform.forward,
    hits,
    0f,
    enemyLayerMask
    );

    for (int i = 0; i < hitCount; i++)
    {
    // Process hits
    }
    }

Unity-Specific Design Patterns

Component-Based Architecture

  1. Single Responsibility Principle:

    • Create focused components that do one thing well rather than monolithic scripts.
    • Compose complex behaviors from simple components.
    // Instead of one large Player class
    public class PlayerHealth : MonoBehaviour { /* ... */ }
    public class PlayerMovement : MonoBehaviour { /* ... */ }
    public class PlayerCombat : MonoBehaviour { /* ... */ }
    public class PlayerInventory : MonoBehaviour { /* ... */ }
  2. Component Communication:

    • Use Unity's built-in methods for component communication:
      • Direct references for tightly coupled components.
      • GetComponent<T>() for occasional access.
      • Events and delegates for decoupled communication.
      • ScriptableObjects for shared data.
    // Using events for decoupled communication
    public class PlayerHealth : MonoBehaviour
    {
    public event Action<float> OnHealthChanged;
    public event Action OnPlayerDeath;

    private float currentHealth;

    public void TakeDamage(float damage)
    {
    currentHealth -= damage;
    OnHealthChanged?.Invoke(currentHealth);

    if (currentHealth <= 0)
    {
    OnPlayerDeath?.Invoke();
    }
    }
    }

    public class UIHealthBar : MonoBehaviour
    {
    [SerializeField] private PlayerHealth playerHealth;
    [SerializeField] private Image healthBarFill;

    private void OnEnable()
    {
    playerHealth.OnHealthChanged += UpdateHealthBar;
    }

    private void OnDisable()
    {
    playerHealth.OnHealthChanged -= UpdateHealthBar;
    }

    private void UpdateHealthBar(float health)
    {
    healthBarFill.fillAmount = health / 100f;
    }
    }

Scriptable Objects for Data Management

  1. Data-Driven Design:

    • Use ScriptableObjects to store game data separate from behavior.
    • Create modular, reusable data assets for items, enemies, levels, etc.
    [CreateAssetMenu(fileName = "NewWeaponData", menuName = "Game/Weapon Data")]
    public class WeaponData : ScriptableObject
    {
    public string weaponName;
    public Sprite weaponIcon;
    public GameObject weaponPrefab;
    public float damage;
    public float fireRate;
    public AudioClip fireSound;
    }

    public class Weapon : MonoBehaviour
    {
    [SerializeField] private WeaponData weaponData;

    private float nextFireTime;

    public void Fire()
    {
    if (Time.time >= nextFireTime)
    {
    // Use weaponData to implement firing logic
    nextFireTime = Time.time + 1f / weaponData.fireRate;
    }
    }
    }
  2. ScriptableObject Events:

    • Implement a ScriptableObject-based event system for global events.
    [CreateAssetMenu(fileName = "NewGameEvent", menuName = "Game/Game Event")]
    public class GameEvent : ScriptableObject
    {
    private List<GameEventListener> listeners = new List<GameEventListener>();

    public void Raise()
    {
    for (int i = listeners.Count - 1; i >= 0; i--)
    {
    listeners[i].OnEventRaised();
    }
    }

    public void RegisterListener(GameEventListener listener)
    {
    if (!listeners.Contains(listener))
    listeners.Add(listener);
    }

    public void UnregisterListener(GameEventListener listener)
    {
    if (listeners.Contains(listener))
    listeners.Remove(listener);
    }
    }

    public class GameEventListener : MonoBehaviour
    {
    [SerializeField] private GameEvent gameEvent;
    [SerializeField] private UnityEvent response;

    private void OnEnable()
    {
    gameEvent.RegisterListener(this);
    }

    private void OnDisable()
    {
    gameEvent.UnregisterListener(this);
    }

    public void OnEventRaised()
    {
    response.Invoke();
    }
    }

Unity-Specific C# Security and Stability

Safe Unity Serialization

  1. Serialization Best Practices:

    • Mark non-serialized fields with [System.NonSerialized] or make them private.
    • Use [SerializeReference] for polymorphic serialization in Unity 2019.3+.
    • Implement ISerializationCallbackReceiver for custom serialization logic.
  2. Scene References and Prefabs:

    • Use prefab variants instead of duplicating prefabs with minor changes.
    • Implement runtime initialization for objects that need scene-specific setup.
    • Use addressables for large assets and dynamic loading.

Error Handling in Unity

  1. Unity-Specific Debugging:

    • Use Debug.Log(), Debug.LogWarning(), and Debug.LogError() appropriately.
    • Implement custom debug visualization with OnDrawGizmos() and OnDrawGizmosSelected().
    • Create editor tools for complex debugging scenarios.
    private void OnDrawGizmosSelected()
    {
    Gizmos.color = Color.red;
    Gizmos.DrawWireSphere(transform.position, detectionRadius);

    Gizmos.color = Color.blue;
    Gizmos.DrawRay(transform.position, transform.forward * attackRange);
    }
  2. Graceful Error Recovery:

    • Implement fallback behaviors for missing components or assets.
    • Use try-catch blocks for operations that might fail at runtime.
    • Log detailed error information to help with debugging.
    public void LoadLevel(string levelName)
    {
    try
    {
    SceneManager.LoadScene(levelName);
    }
    catch (Exception e)
    {
    Debug.LogError($"Failed to load level '{levelName}': {e.Message}");
    // Fallback to main menu or default level
    SceneManager.LoadScene("MainMenu");
    }
    }

Unity Editor Extensions

Custom Editors and Inspector Tools

  1. Custom Inspectors:

    • Create custom inspectors for complex components to improve workflow.
    • Use [CustomEditor] attribute to extend the Unity editor.
    [CustomEditor(typeof(EnemySpawner))]
    public class EnemySpawnerEditor : Editor
    {
    public override void OnInspectorGUI()
    {
    EnemySpawner spawner = (EnemySpawner)target;

    DrawDefaultInspector();

    EditorGUILayout.Space();

    if (GUILayout.Button("Spawn Enemy"))
    {
    spawner.SpawnEnemy();
    }

    if (GUILayout.Button("Clear All Enemies"))
    {
    spawner.ClearEnemies();
    }
    }
    }
  2. Editor Tools:

    • Create editor tools for repetitive tasks or complex setups.
    • Use EditorWindow for custom windows and tools.
    public class LevelSetupWindow : EditorWindow
    {
    private GameObject playerPrefab;
    private GameObject[] enemyPrefabs;
    private int enemyCount = 5;

    [MenuItem("Tools/Level Setup")]
    public static void ShowWindow()
    {
    GetWindow<LevelSetupWindow>("Level Setup");
    }

    private void OnGUI()
    {
    GUILayout.Label("Level Setup Tool", EditorStyles.boldLabel);

    playerPrefab = (GameObject)EditorGUILayout.ObjectField("Player Prefab", playerPrefab, typeof(GameObject), false);

    EditorGUILayout.Space();

    GUILayout.Label("Enemy Setup", EditorStyles.boldLabel);
    enemyCount = EditorGUILayout.IntField("Enemy Count", enemyCount);

    if (GUILayout.Button("Setup Level"))
    {
    SetupLevel();
    }
    }

    private void SetupLevel()
    {
    // Implementation of level setup logic
    }
    }

Conclusion

Applying these Unity-specific C# best practices will help you create games that are not only functional but also maintainable, performant, and scalable. Remember that game development often requires balancing between ideal code architecture and practical performance considerations. Always profile your game to identify bottlenecks and optimize the areas that will have the most significant impact on player experience.

As you continue your Unity development journey, regularly revisit these practices and stay updated with the latest Unity features and optimizations. The Unity ecosystem evolves rapidly, and new tools and techniques are constantly emerging to help you write better game code.