| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Name | Name | Last commit date | ||
|---|---|---|---|---|
These coding standards represent recommendations born from 20 years of game development experience. They are opinions I've formed and refined throughout my career, and they continue to evolve as I learn and as the industry progresses.
An Important Note on Adaptation:
When working as part of an established team, it is crucial to adapt to the team's existing coding standards, even if they differ from these recommendations. Consistency within a codebase is more valuable than individual preference. However, when provided with an opportunity to set standards, provide guidance, or start fresh on a new project, these are the ever-evolving standards that I hold myself to.
These standards are shared openly in the hope that they may be useful to others, but they should be adapted and modified to suit your team's needs and context.
If you like my work then please consider showing your support by buying me a brew
These coding standards are based on Microsoft's C# Coding Conventions and .NET Naming Guidelines, with specific adaptations for Unity development and team preferences.
Following Microsoft's naming guidelines with Unity-specific adaptations.
public class PlayerController { }
public struct PlayerStats { }
public interface IHealthSystem { }public void CalculateDamage()
{
// Implementation
}
public async UniTask LoadSceneAsync(CancellationToken cancellationToken)
{
// Implementation
}public int MaxHealth { get; private set; }
protected float JumpForce;
private int _currentHealth;
private readonly Transform _transform;
// Unity Inspector - exposing auto-property backing field
[field: SerializeField] public int StartingHealth { get; private set; }public void ApplyDamage(int damageAmount, DamageType damageType)
{
int remainingHealth = _currentHealth - damageAmount;
}private const int MaxPlayers = 4;
private static readonly Vector3 SpawnPosition = new Vector3(0, 1, 0);Avoid Hungarian notation and type prefixes in variable names.
While Unity's modern coding standards (like Microsoft's) don't use Hungarian notation, you might encounter it in older tutorials or legacy codebases. You might also see m_ prefixes in some older Unity example code—a remnant from Unity's C++ engine heritage where this was standard practice.
// Sometimes seen in older tutorials or legacy code
public class Example : MonoBehaviour
{
public GameObject goPlayer; // Hungarian notation
public Transform tTarget; // Type prefix
private float m_Speed = 5.0f; // C++ style member prefix
private int iHealth = 100; // Hungarian notation
private string strPlayerName; // Type prefix
}
// My preference - descriptive names without type prefixes
public class Example : MonoBehaviour
{
public GameObject Player { get; set; }
public Transform Target { get; set; }
private float _speed = 5.0f;
private int _health = 100;
private string _playerName;
}Here's why I avoid Hungarian notation and type prefixes:
The redundancy bothers me. We're already declaring the type (or inferring it with var), so prefixing with type information is saying the same thing twice. It's like naming your dog "DogBuddy"—technically clear, but unnecessarily redundant.
Modern IDEs eliminate the need. Hover over any variable and you see its type instantly. The argument for Hungarian notation made more sense when we were coding in Notepad, but those days are (thankfully) behind us.
It leads to maintenance lies. I've seen too much code where someone changed float fSpeed to double fSpeed and forgot to update the prefix. Now the notation is actively misleading. A variable named speed can change type without becoming a lie.
It obscures meaning. Compare rbPlayer vs playerRigidbody—which one immediately tells you it's the player's rigidbody? The descriptive name wins every time.
That said, I do use one form of prefixing—the underscore for private fields (_health). But that's about scope, not type, and it serves a different purpose: immediately distinguishing fields from local variables and parameters.
Unity's modern standards follow Microsoft's conventions and avoid these prefixes too. If you're working with legacy code or a team that uses Hungarian notation, adapt to their style. But for new projects, following the modern Microsoft/Unity approach of descriptive names without type prefixes leads to cleaner, more maintainable code.
Spell it out—your future self will thank you.
I've spent too much time staring at variables like plrMgr wondering "Is that PlayerManager or PlayerMigrator?" The few keystrokes saved by abbreviating aren't worth the cognitive load of deciphering them later. This is especially true when you're debugging at the end of a long day or when a teammate is trying to understand your code.
// Avoid - cryptic abbreviations
public class GameController
{
private PlrMgr _plrMgr;
private EnemyCtrl _enemCtrl;
private int _maxPlrs = 4;
private float _respTmr;
private bool _canRspn;
}
// Good - clear, spelled-out names
public class GameController
{
[SerializeField] private PlayerManager _playerManager;
[SerializeField] private EnemyController _enemyController;
private int _maxPlayers = 4;
private float _respawnTimer;
private bool _canRespawn;
}Common abbreviations that still make me pause:
The exceptions where abbreviations make sense:
Sometimes abbreviations are so universal that spelling them out would be weird:
// These are fine - universally understood
private UI _userInterface; // UI is clearer than UserInterface
private API _apiEndpoint; // Everyone knows API
private GUI _guiManager; // GUI is the standard term
private AI _aiController; // AI is more recognizableMathematical contexts are different:
In mathematical or algebraic functions, single letters or standard mathematical abbreviations often improve readability by matching the domain language:
// Good - matches mathematical convention
public float CalculateDistance(Vector3 a, Vector3 b)
{
float dx = b.x - a.x;
float dy = b.y - a.y;
float dz = b.z - a.z;
return Mathf.Sqrt(dx * dx + dy * dy + dz * dz);
}
// Good - standard physics abbreviations in physics context
public void ApplyForce(float m, Vector3 a) // mass, acceleration
{
Vector3 f = m * a; // f = ma is universally understood
_rigidbody.AddForce(f);
}
// Overly verbose for mathematical context
public float CalculateDistance(Vector3 firstPoint, Vector3 secondPoint)
{
float differenceInX = secondPoint.x - firstPoint.x;
float differenceInY = secondPoint.y - firstPoint.y;
// This actually makes the formula harder to read
}Loop variables—the eternal debate:
I still use i, j, k for simple loop counters. It's a convention as old as programming itself, and everyone knows what they mean:
// This is fine - universal convention
for (int i = 0; i < items.Count; i++)
{
ProcessItem(items[i]);
}
// But for nested loops, consider being clearer
for (int row = 0; row < grid.Height; row++)
{
for (int col = 0; col < grid.Width; col++)
{
grid[row, col] = initialValue;
}
}The readability test:
When I'm deciding whether to abbreviate, I ask myself: "If I had to debug this code at 2 AM after being woken up by a production issue, would I immediately know what this variable represents?" If there's even a moment's hesitation, I spell it out.
The only exception is when you're deliberately obfuscating code for security through obscurity (though if you're relying on that, you probably have bigger problems).
A confession: I used to abbreviate everything, thinking it made me look like a "real" programmer. Now I realize that real programmers write code that others (including future-them) can understand without a decoder ring.
Favor expressiveness with preference for concise names, but accept verbose names where they add value.
// Good - concise and clear
public void Jump()
{
// Implementation
}
// Good - verbose but adds clarity
public async UniTask WaitForAnimationCompleteAsync(CancellationToken cancellationToken)
{
// Implementation
}
// Avoid - unnecessarily verbose
public void JumpPlayerCharacterVertically()
{
// Implementation
}Preference: Tabs over spaces ⚠️ Differs from Microsoft standard (4 spaces)
Note on ongoing debate:
This is an area of continued self-questioning. Tabs are preferred for visual clarity and historical efficiency (though compiler optimization has largely eliminated performance differences). However, spaces offer more consistent rendering across editors and tools. Additionally, LLMs commonly default to spaces, with many struggling to consistently use tabs when generating code.
Ultimately: Consistency within a project trumps personal preference. Adapt to the established standard.
Always use explicit access modifiers ⚠️ Stricter than Microsoft standard
// Good - explicit intent
private int _health;
public string Name { get; private set; }
private void UpdateHealth()
{
// Implementation
}
// Avoid - implicit access modifier
int _health; // Should be: private int _health;Rationale: Highlights coder intent and maintains readability through consistency.
// Properties - inline
[SerializeField] private int _maxHealth;
[field: SerializeField] public int CurrentHealth { get; private set; }
// Methods - above on separate line
[ContextMenu("Reset Health")]
private void ResetHealth()
{
CurrentHealth = _maxHealth;
}Prefer var where type is clear from context
// Good - type is obvious
var player = new PlayerController();
var position = new Vector3(0, 1, 0);
var enemies = new List<Enemy>();
// Avoid - type unclear
var data = GetData(); // What type is this?
// Good - explicit when unclear
PlayerData data = GetData();
Complex<Nested<Generic>> result = CalculateComplexResult();Rationale: Simplifies refactoring when types change and removes duplicate information. Improves readability by avoiding repeated type names of varying lengths.
Note: While IDEs display inferred types on hover, consider explicit types when the inferred type is complex or unclear, particularly for code reviews conducted outside the IDE (terminal, GitHub, etc.).
Use non-cuddley braces (Allman style) - opening braces on their own line.
This one brings back memories! My former co-founder Dean and I had countless debates about this. He was firmly in the cuddley camp (K&R style), while I've always been team non-cuddley. We'd literally spend lunch breaks arguing about whether braces should "cuddle" up to their statements or stand proudly on their own lines.
The non-cuddley style places opening braces on a new line, aligning them vertically with their closing braces. For me, this creates clearer visual boundaries—I can scan down the left margin and immediately see where blocks begin and end.
// My preference - non-cuddley (Allman style)
if (health <= 0)
{
Die();
return;
}
public void ProcessPlayer(Player player)
{
if (player == null)
{
return;
}
UpdatePlayerState(player);
}
// Dean's preference - cuddley braces (K&R style)
if (health <= 0) {
Die();
return;
}
public void ProcessPlayer(Player player) {
if (player == null) {
return;
}
UpdatePlayerState(player);
}Why I'm Team Non-Cuddley (Despite Dean's Historical Point):
Dean's argument was that dropping brackets onto a new line could cause bugs—and he was actually referring to a real issue. In JavaScript, there's a genuine technical problem with non-cuddley braces due to Automatic Semicolon Insertion (ASI):
// JavaScript - This breaks with non-cuddley!
return
{
success: true
};
// JavaScript inserts a semicolon after 'return', returning undefined!
// Must use cuddley style in JavaScript for this case
return {
success: true
};This wasn't some theoretical edge case—it was a real bug that bit many developers. Languages like Go actually enforce cuddley braces partly because of lessons learned from JavaScript's ASI issues.
But here's the thing—we're writing C#, not JavaScript. C# doesn't have ASI, so this particular issue doesn't exist. After years of this debate (and trying both styles extensively), I've stuck with non-cuddley for a simple reason: when I'm exhausted at the end of a long coding session, those aligned braces are like visual anchors. I can immediately see the structure of the code without having to parse where blocks begin and end.
The truth is, both work in C#. This is one of those debates where context matters—use cuddley in JavaScript to avoid bugs, but in C#, pick based on readability preference and stick with it consistently.
Order class members by access level to emphasize the public API first.
Organize properties and methods in the following order:
Within each access level, group similar members together (properties, then methods). Serialized fields maintain the access hierarchy even though they're exposed in the Inspector.
public class PlayerController : MonoBehaviour, IDamageable
{
// 1. Public properties and fields (API surface)
public int MaxHealth { get; private set; } = 100;
public float MoveSpeed { get; set; } = 5f;
[field: SerializeField] public int StartingHealth { get; private set; }
// 2. Serialized protected fields (Inspector-exposed, inheritable)
[SerializeField] protected float JumpForce = 10f;
[SerializeField] protected LayerMask GroundLayers;
// 3. Serialized private fields (Inspector-exposed, internal)
[SerializeField] private GameObject _playerModel;
[SerializeField] private AudioClip _jumpSound;
[SerializeField] private Rigidbody _rigidbody;
// 4. Internal members (assembly-visible)
internal int DebugHealthOverride { get; set; }
internal void ResetForTesting()
{
_currentHealth = StartingHealth;
_isGrounded = false;
}
// 5. Explicit interface implementations (accessible only via interface)
bool IDamageable.IsInvulnerable => _isInvulnerable;
void IDamageable.ApplyDamage(int amount, DamageType type)
{
var modifiedDamage = CalculateDamage(amount, type);
TakeDamage(modifiedDamage);
}
// 6. Protected members (for inheritance)
protected virtual float DamageMultiplier => 1.0f;
// 7. Private fields (internal state)
private int _currentHealth;
private bool _isGrounded;
private bool _isInvulnerable;
// 8. Public methods (API)
public void TakeDamage(int damage)
{
_currentHealth = Mathf.Max(0, _currentHealth - damage);
OnDamageTaken(damage);
}
public void Jump()
{
if (!_isGrounded) return;
_rigidbody.AddForce(Vector3.up * JumpForce, ForceMode.Impulse);
}
// 9. Protected methods (extensibility points)
protected virtual void Start()
{
_currentHealth = StartingHealth;
}
protected virtual void OnDamageTaken(int damage)
{
// Override point for derived classes
}
// 10. Private methods (implementation details)
private void UpdateGroundedState()
{
_isGrounded = Physics.Raycast(transform.position, Vector3.down, 1.1f);
}
private void PlayJumpSound()
{
if (_jumpSound != null)
{
AudioSource.PlayClipAtPoint(_jumpSound, transform.position);
}
}
private int CalculateDamage(int amount, DamageType type)
{
return Mathf.RoundToInt(amount * DamageMultiplier);
}
}Rationale:
Avoid using #region directives - they often indicate violations of Single Responsibility Principle.
Regions are frequently used to hide complexity and group related functionality within a class. However, if a class needs regions to organize its code, it's likely doing too much. Each region typically represents a different responsibility that should be extracted into its own class.
// Avoid - regions hiding multiple responsibilities
public class PlayerController : MonoBehaviour
{
#region Health Management
private int _health;
public void TakeDamage(int amount)
{
// Implementation
}
public void Heal(int amount)
{
// Implementation
}
private void Die()
{
// Implementation
}
#endregion
#region Movement
private Vector3 _velocity;
public void Move(Vector3 direction)
{
// Implementation
}
public void Jump()
{
// Implementation
}
private void ApplyGravity()
{
// Implementation
}
#endregion
#region Inventory
private List<Item> _items;
public void AddItem(Item item)
{
// Implementation
}
public void RemoveItem(Item item)
{
// Implementation
}
private void SortInventory()
{
// Implementation
}
#endregion
}
// Good - separate classes for separate responsibilities
public class PlayerController : MonoBehaviour
{
[SerializeField] private HealthSystem _healthSystem;
[SerializeField] private MovementController _movement;
[SerializeField] private InventorySystem _inventory;
}
public class HealthSystem : MonoBehaviour
{
private int _health;
public void TakeDamage(int amount)
{
// Implementation
}
public void Heal(int amount)
{
// Implementation
}
private void Die()
{
// Implementation
}
}
public class MovementController : MonoBehaviour
{
private Vector3 _velocity;
public void Move(Vector3 direction)
{
// Implementation
}
public void Jump()
{
// Implementation
}
private void ApplyGravity()
{
// Implementation
}
}Code Smell Indicators:
When you find yourself wanting to use regions, consider these alternatives:
Rare Acceptable Uses:
In very limited cases, regions might be acceptable:
// Acceptable - grouping generated or required boilerplate
public partial class GeneratedClass
{
#region Auto-Generated Code - Do Not Modify
// Generated code from tools
#endregion
}
// Acceptable - platform-specific implementations
public class CrossPlatformService
{
#if UNITY_EDITOR
// Editor-only implementation
#elif UNITY_IOS
// iOS-specific implementation
#elif UNITY_ANDROID
// Android-specific implementation
#endif
}Rationale:
If your class is large enough to "need" regions, it's large enough to be refactored into smaller, focused classes.
Always use braces for multi-line blocks. Omit only for single-line guard clauses.
// Good - single line guard clause
if (health <= 0) return;
if (!isActive) continue;
// Good - braced blocks for multi-statement or complex logic
if (health <= 0)
{
Die();
}
// Good - both if and else have braces
if (isAlive)
{
UpdateHealth();
}
else
{
HandleDeath();
}
// Avoid - inconsistent bracing
if (isAlive)
UpdateHealth();
else
{
HandleDeath();
ResetStats();
}Inline returns for short guard clauses. Use closure for complex returns. ⚠️ Personal preference
// Good - inline guard clauses
if (target == null) return;
if (!IsValid(data)) return false;
// Good - complex returns in closure
if (complexCondition)
{
return CalculateComplexValue();
}
// Avoid - standalone return on own line after complex logic
if (complexCondition)
return CalculateComplexValue();Rationale: Inline guard clauses allow multiple one-line guards to be compacted into a readable, aligned block (which could itself be abstracted into a guard method). Use closures for complex guards that need space for context, comments, or easier breakpointing.
Prefer using declarations (non-block scoped) over using statements with braces.
// Good - using declaration, disposed at end of enclosing scope
public void ProcessFile(string path)
{
using var reader = new StreamReader(path);
var content = reader.ReadToEnd();
ProcessContent(content);
// reader automatically disposed here
}
// Avoid - traditional using block with braces
public void ProcessFile(string path)
{
using (var reader = new StreamReader(path))
{
var content = reader.ReadToEnd();
ProcessContent(content);
} // reader disposed here
}Exception: When precise disposal timing is critical (e.g., releasing file locks before opening another resource), use traditional using blocks for explicit control.
// Good - when disposal timing matters
public void ProcessSequentialFiles()
{
using (var reader = new StreamReader(_path1))
{
ProcessFile(reader);
} // Explicitly disposed here before next file
using (var reader = new StreamReader(_path2))
{
ProcessFile(reader);
}
}Rationale: Reduces nesting and improves readability. The disposable is automatically disposed at the end of the enclosing scope. Functional requirements trump formatting preferences when disposal timing is important.
Favor early returns, guard clauses, and abstracted methods over deeply nested code.
// Good - flat structure with guard clauses
public void ProcessPlayer(Player player)
{
if (player == null) return;
if (!player.IsActive) return;
if (player.Health <= 0) return;
UpdatePlayerState(player);
ProcessPlayerActions(player);
}
// Avoid - deep nesting
public void ProcessPlayer(Player player)
{
if (player != null)
{
if (player.IsActive)
{
if (player.Health > 0)
{
UpdatePlayerState(player);
ProcessPlayerActions(player);
}
}
}
}Follow SOLID principles with emphasis on:
Single Responsibility Principle (SRP) - Primary focus
// Good - single responsibilities
public class HealthSystem
{
public void TakeDamage(int amount)
{
// Implementation
}
public void Heal(int amount)
{
// Implementation
}
}
public class PlayerMovement
{
public void Move(Vector3 direction)
{
// Implementation
}
public void Jump()
{
// Implementation
}
}
// Avoid - multiple responsibilities
public class Player
{
public void TakeDamage(int amount) { }
public void Move(Vector3 direction) { }
public void SaveToDatabase() { }
public void RenderHealthBar() { }
}Favor readability over minor performance gains where complexity has a cost in interpretation time.
// Good - readable
public bool IsPlayerEligibleForReward()
{
var questId = _currentQuestId;
var requiredLevel = 10;
var rewardId = _currentRewardId;
bool hasCompletedQuest = _questSystem.IsQuestComplete(questId);
bool hasMinimumLevel = _player.Level >= requiredLevel;
bool hasNotClaimedBefore = !_rewardSystem.HasClaimed(rewardId);
return hasCompletedQuest && hasMinimumLevel && hasNotClaimedBefore;
}
// Avoid - micro-optimized but less clear
public bool IsPlayerEligibleForReward() =>
_questSystem.IsQuestComplete(_currentQuestId) && _player.Level >= 10 &&
!_rewardSystem.HasClaimed(_currentRewardId);Prefer Inspector assignment over GetComponent for dependencies.
Assign component dependencies through the Unity Inspector rather than finding them at runtime with GetComponent. This approach exposes missing dependencies immediately in the editor rather than causing runtime errors.
// Good - Inspector assignment with clear dependencies
public class PlayerController : MonoBehaviour
{
[Header("Required Components")]
[SerializeField] private Rigidbody _rigidbody;
[SerializeField] private Animator _animator;
[SerializeField] private AudioSource _audioSource;
[Header("Dependencies")]
[SerializeField] private HealthSystem _healthSystem;
[SerializeField] private InventoryManager _inventory;
private void Start()
{
// Components are already assigned and validated
_rigidbody.velocity = Vector3.zero;
}
}
// Avoid - runtime component lookup
public class PlayerController : MonoBehaviour
{
private Rigidbody _rigidbody;
private Animator _animator;
private HealthSystem _healthSystem;
private void Awake()
{
// Runtime lookups add overhead and can fail silently
_rigidbody = GetComponent<Rigidbody>();
_animator = GetComponent<Animator>();
_healthSystem = GetComponentInChildren<HealthSystem>();
}
}Benefits:
Exception - Dynamic or Optional Components:
Use GetComponent when components are truly optional or added dynamically:
public class InteractionHandler : MonoBehaviour
{
private void OnTriggerEnter(Collider other)
{
// Dynamic lookup for optional component on other object
var interactable = other.GetComponent<IInteractable>();
interactable?.Interact();
}
}Runtime Validation:
Validate references are set using OnValidate (editor) or Awake (runtime):
public class PlayerController : MonoBehaviour
{
[SerializeField] private Rigidbody _rigidbody;
[SerializeField] private HealthSystem _healthSystem;
[SerializeField] private WeaponSystem _weaponSystem;
// Editor-time validation
private void OnValidate()
{
if (_rigidbody == null)
{
Debug.LogWarning($"Rigidbody is not assigned on {gameObject.name}", this);
}
if (_healthSystem == null)
{
Debug.LogWarning($"HealthSystem is not assigned on {gameObject.name}", this);
}
}
// Runtime validation with errors
private void Awake()
{
ValidateReferences();
}
private void ValidateReferences()
{
var missingRefs = new System.Collections.Generic.List<string>();
if (_rigidbody == null) missingRefs.Add(nameof(_rigidbody));
if (_healthSystem == null) missingRefs.Add(nameof(_healthSystem));
if (_weaponSystem == null) missingRefs.Add(nameof(_weaponSystem));
if (missingRefs.Count > 0)
{
Debug.LogError($"Missing references on {gameObject.name}: {string.Join(", ", missingRefs)}", this);
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPaused = true;
#endif
}
}
}Understand both child-to-parent and parent-to-child patterns, choosing based on context.
Unity components can communicate through two primary patterns, each with distinct advantages. Choose based on your specific requirements for modularity, performance, and control.
Children look up through the hierarchy to find dependencies and subscribe to changes:
// Child component looks up and subscribes
public class PlayerColorIndicator : MonoBehaviour
{
[SerializeField] private Renderer _renderer;
private IPlayerState _playerState;
private MaterialPropertyBlock _propertyBlock;
private void Start()
{
_propertyBlock = new MaterialPropertyBlock();
// Look up through hierarchy for player state
_playerState = GetComponentInParent<IPlayerState>();
if (_playerState != null)
{
// Subscribe to changes
_playerState.OnHealthChanged += UpdateColorBasedOnHealth;
_playerState.OnStateChanged += UpdateColorBasedOnState;
// Initial update
UpdateColorBasedOnHealth(_playerState.CurrentHealth);
}
}
private void UpdateColorBasedOnHealth(float health)
{
var healthPercent = health / _playerState.MaxHealth;
var color = Color.Lerp(Color.red, Color.green, healthPercent);
_renderer.GetPropertyBlock(_propertyBlock);
_propertyBlock.SetColor("_Color", color);
_renderer.SetPropertyBlock(_propertyBlock);
}
private void OnDestroy()
{
// Unsubscribe to prevent memory leaks
if (_playerState != null)
{
_playerState.OnHealthChanged -= UpdateColorBasedOnHealth;
_playerState.OnStateChanged -= UpdateColorBasedOnState;
}
}
}Advantages:
Disadvantages:
Best for:
Parent maintains references to children and directly controls them:
// Parent directly manages children
public class WeaponSystem : MonoBehaviour
{
[Header("Core Components")]
[SerializeField] private ProjectileLauncher _launcher;
[SerializeField] private AmmoDisplay _ammoDisplay;
[SerializeField] private MuzzleFlash _muzzleFlash;
private int _currentAmmo = 30;
private void Start()
{
// Initialize all controlled components
_ammoDisplay.SetAmmo(_currentAmmo);
_muzzleFlash.Initialize(this);
}
public void Fire()
{
if (_currentAmmo <= 0) return;
// Direct, ordered control of components
_launcher.Launch(CalculateDamage());
_muzzleFlash.Play();
_currentAmmo--;
_ammoDisplay.SetAmmo(_currentAmmo);
// Parent controls exact execution order and can optimize
if (_currentAmmo == 0)
{
_ammoDisplay.ShowEmptyWarning();
}
}
}Advantages:
Disadvantages:
Best for:
Consider these factors when selecting a communication pattern:
| Factor | Child-to-Parent | Parent-to-Child |
|---|---|---|
| Component is optional | ✅ Preferred | ❌ Avoid |
| Need exact execution order | ❌ Difficult | ✅ Natural |
| Designer needs to add/remove | ✅ Excellent | ❌ Requires setup |
| Performance critical | ⚠️ Consider overhead | ✅ Can optimize |
| Component reusability | ✅ High | ❌ Low |
| Debugging requirements | ⚠️ Event chains | ✅ Direct calls |
| Memory management | ⚠️ Must unsubscribe | ✅ Simple |
Often the best solution combines both patterns:
public class PlayerSystem : MonoBehaviour
{
// Critical components use direct references
[Header("Core Systems")]
[SerializeField] private PlayerMovement _movement;
[SerializeField] private WeaponController _weapon;
// Optional components use events
public event System.Action<float> OnHealthChanged;
public event System.Action<PlayerState> OnStateChanged;
private void UpdateHealth(float newHealth)
{
_health = newHealth;
// Direct control for critical components
if (_health <= 0)
{
_movement.DisableMovement();
_weapon.DisableWeapons();
}
// Events for optional listeners
OnHealthChanged?.Invoke(_health);
}
}
// Optional UI component uses child-to-parent
public class HealthBarUI : MonoBehaviour
{
private void Start()
{
var player = GetComponentInParent<PlayerSystem>();
if (player != null)
{
player.OnHealthChanged += UpdateHealthBar;
}
}
}Regardless of pattern choice, interfaces provide flexibility:
public interface IHealth
{
float CurrentHealth { get; }
float MaxHealth { get; }
event System.Action<float> OnHealthChanged;
event System.Action OnDeath;
}
// Works with either pattern
public class HealthDisplay : MonoBehaviour
{
[SerializeField] private bool _searchInParents = true;
[SerializeField] private Image _healthBar;
// Can be assigned in Inspector (parent-to-child)
// or found at runtime (child-to-parent)
private IHealth _healthSource;
private void Start()
{
if (_healthSource == null && _searchInParents)
{
_healthSource = GetComponentInParent<IHealth>();
}
if (_healthSource != null)
{
_healthSource.OnHealthChanged += UpdateDisplay;
UpdateDisplay(_healthSource.CurrentHealth);
}
}
private void UpdateDisplay(float health)
{
_healthBar.fillAmount = health / _healthSource.MaxHealth;
}
}Key Principle: Choose the pattern that best matches your component's relationship and requirements. Critical, owned components benefit from direct control, while optional, modular components thrive with lookup patterns.
Consider carefully before implementing messaging systems - often direct communication or dedicated intermediaries are clearer.
This is an area where my thinking has evolved significantly over the years. I was once a strong advocate for messaging systems—they felt like the "proper" architectural solution. But experience has taught me that what seems clever in theory often becomes a burden in practice. This evolution away from messaging systems toward more explicit communication patterns reflects a hard-won preference for clarity over cleverness.
I'll be honest—early in my career, I thought messaging systems were the answer to everything.
// The allure of messaging systems - everything is decoupled!
public class GameEventBus
{
private static Dictionary<Type, List<Delegate>> _subscribers = new();
public static void Subscribe<T>(Action<T> handler)
{
if (!_subscribers.ContainsKey(typeof(T)))
{
_subscribers[typeof(T)] = new List<Delegate>();
}
_subscribers[typeof(T)].Add(handler);
}
public static void Publish<T>(T message)
{
if (_subscribers.TryGetValue(typeof(T), out var handlers))
{
foreach (Action<T> handler in handlers)
{
handler?.Invoke(message);
}
}
}
}
// Usage seems simple and decoupled
public class ScoreManager : MonoBehaviour
{
private void Start()
{
GameEventBus.Subscribe<EnemyKilledMessage>(OnEnemyKilled);
}
private void OnEnemyKilled(EnemyKilledMessage msg)
{
AddScore(msg.Points);
}
}The problems that slowly revealed themselves:
I remember the exact project where my faith in messaging systems started to crack. We had built this beautiful, completely decoupled system where everything communicated via messages. It felt so clean! Then came the bugs...
This is the most common defense of messaging systems, and it sounds compelling. But I've come to believe it's based on a misunderstanding of what "decoupling" actually means and what it's worth.
The Decoupling Illusion:
Yes, messaging systems create syntactic decoupling—your classes don't directly reference each other. But they create semantic coupling that's actually worse:
// This looks decoupled...
public class PlayerController : MonoBehaviour
{
private void TakeDamage(int amount)
{
EventBus.Publish(new PlayerDamagedMessage { Amount = amount });
// But who's listening? What order will they process?
// What if someone needs the damage source?
// What if we need to know if the damage was blocked?
}
}
// But it's actually creating worse coupling:
// - UI assumes message comes before death message
// - Audio system assumes player reference is valid
// - Save system assumes damage is final (not blocked)
// - Achievement system needs damage source (not provided)The "Decoupled" Code That Isn't:
In practice, "decoupled" messaging systems often end up with:
True Decoupling vs False Decoupling:
// False decoupling - hidden dependencies via messages
public class HealthBar : MonoBehaviour
{
private void Start()
{
EventBus.Subscribe<PlayerDamagedMessage>(OnDamaged);
EventBus.Subscribe<PlayerHealedMessage>(OnHealed);
EventBus.Subscribe<PlayerRespawnedMessage>(OnRespawned);
// Secretly depends on PlayerController, just indirectly
}
}
// True decoupling - explicit interface
public class HealthBar : MonoBehaviour
{
[SerializeField] private IHealth _healthSource; // Could be player, enemy, anything
private void Start()
{
if (_healthSource != null)
{
_healthSource.OnHealthChanged += UpdateDisplay;
}
}
}The second example is MORE decoupled because HealthBar truly doesn't care what provides the health—it could be a player, enemy, or building. The first example is tightly coupled to player-specific messages.
The Debugging Argument Against "Decoupling":
When your "decoupled" system has a bug:
That three-hour debugging session I mentioned? With direct references, it would have been three minutes.
The real turning point came when I spent three hours debugging an issue, only to realize that the solution was to move one subscription from Start() to Awake(). That's when I knew something was wrong with the approach, not the implementation.
// Better - Direct dependency with interface
public class ScoreManager : MonoBehaviour
{
private IEnemySystem _enemySystem;
private void Start()
{
_enemySystem = ServiceLocator.Get<IEnemySystem>();
_enemySystem.OnEnemyKilled += HandleEnemyKilled;
}
private void HandleEnemyKilled(Enemy enemy, int points)
{
AddScore(points);
// Clear, traceable, debuggable
}
}Now, I'm not saying messaging systems are always bad—that would be replacing one dogma with another. Through trial and error, I've found they still have their place:
1. True Broadcasting (1-to-Many Unknown)
// Achievement system - unknown number of achievement listeners
public interface IAchievementEvent { }
public class FirstBloodAchievement : IAchievementEvent
{
public string PlayerId { get; set; }
public float TimeElapsed { get; set; }
}
// Multiple systems might care, but we don't know which
// Analytics, UI, Save System, Steam Integration, etc.2. Cross-Scene Communication
// When systems span multiple scenes
public class CrossSceneEventBus
{
// Useful when you can't have direct references
// But consider if ScriptableObject events might be better
}3. Modding/Plugin Architecture
// When external code needs to hook into your systems
public class ModEventSystem
{
// Mods can subscribe without modifying core game
}After years of flip-flopping between "decouple everything" and "just reference things directly," I've found what works for me. The combination of Service Locator pattern with explicit interfaces has become my go-to approach. It's not perfect, but it's pragmatic:
// Clear contracts without tight coupling
public interface IPlayerHealth
{
float Current { get; }
float Max { get; }
event Action<float> OnHealthChanged;
event Action OnDeath;
}
public interface ICombatLog
{
void LogDamage(GameObject source, GameObject target, float damage);
void LogDeath(GameObject victim, GameObject killer);
}
// Systems declare what they need explicitly
public class DamageNumberUI : MonoBehaviour
{
private ICombatLog _combatLog;
private void Start()
{
// Explicit dependency - clear what this system needs
_combatLog = ServiceLocator.Get<ICombatLog>();
_combatLog.OnDamageLogged += ShowDamageNumber;
}
}Why this approach has stuck with me:
The last point has been particularly valuable. With messaging systems, you could subscribe to 20 different messages and it would look fine. With explicit interface dependencies, the constructor or Start method becomes a monster, and that discomfort drives better design.
This pattern emerged naturally once I stopped fighting the "too many dependencies" signal. Instead of having systems talk to each other through messages or complex dependency chains, I now create explicit coordinators:
// Instead of messaging between UI, Audio, VFX, and GamePlay...
public class CombatCoordinator : MonoBehaviour
{
[Header("Systems to Coordinate")]
[SerializeField] private CombatUI _ui;
[SerializeField] private CombatAudio _audio;
[SerializeField] private CombatVFX _vfx;
[SerializeField] private CombatStats _stats;
public void ProcessHit(HitInfo hit)
{
// Explicit orchestration - clear order of operations
var damage = _stats.CalculateDamage(hit);
_ui.ShowDamage(hit.Position, damage);
_audio.PlayHitSound(hit.Type, hit.Position);
_vfx.SpawnHitEffect(hit.Position, hit.Normal);
_stats.ApplyDamage(hit.Target, damage);
// Clear what happens and in what order
}
}This feels so much better than the old way. I know exactly what happens when a hit occurs, in what order, and I can debug it with a simple breakpoint.
| Scenario | Recommended Approach | Avoid |
|---|---|---|
| 2-3 systems communicating | Direct interfaces | Message bus |
| UI responding to game state | Events on interfaces | Global events |
| System needs 5+ dependencies | Create intermediary | Message spam |
| Cross-scene data | ScriptableObject events | Static message bus |
| Unknown subscribers | Consider messaging | Force-fitting direct calls |
| Mod support needed | Message bus or hooks | Direct references |
I've been there—inheriting or having built a system where everything uses messages. Here's how I've approached gradually moving away:
// Before: Multiple related messages
PublishMessage(new PlayerDamagedMessage(damage));
PublishMessage(new PlayerHealthUpdatedMessage(health));
PublishMessage(new PlayerDiedMessage());
// After: Coherent interface
public interface IPlayerHealth
{
event Action<DamageInfo> OnDamaged;
event Action<float> OnHealthChanged;
event Action<DeathInfo> OnDeath;
}A Final Thought:
My evolution from messaging systems to more direct communication wasn't overnight, and I still question it sometimes. There are days when I see a particularly elegant message-based solution and think "maybe I was too hasty..." But then I remember those three-hour debugging sessions, and I'm reminded why I changed my approach.
The key question I now ask myself: "If I had to debug this at 2 AM, would I thank past-me or curse past-me?" Usually, that leads me away from clever messaging and toward boringly obvious direct connections. And boring, I've learned, is often a virtue in code.
Prefer async/await patterns with UniTask over traditional coroutines.
// Good - async/await with UniTask
public async UniTask LoadSceneAsync(string sceneName, CancellationToken cancellationToken)
{
await SceneManager.LoadSceneAsync(sceneName)
.ToUniTask(cancellationToken: cancellationToken);
}
// Avoid - coroutines for new code
public IEnumerator LoadSceneCoroutine()
{
yield return SceneManager.LoadSceneAsync(sceneName);
}Use cancellation tokens extensively with async code. Link with MonoBehaviour lifecycle.
public class PlayerController : MonoBehaviour
{
private CancellationTokenSource _cts;
private void Start()
{
// Combine with destroyCancellationToken
_cts = CancellationTokenSource.CreateLinkedTokenSource(this.destroyCancellationToken);
InitializeAsync(_cts.Token).Forget();
}
private async UniTask InitializeAsync(CancellationToken cancellationToken)
{
await LoadPlayerDataAsync(cancellationToken);
await SetupPlayerSystemsAsync(cancellationToken);
}
private void OnDestroy()
{
_cts?.Cancel();
_cts?.Dispose();
}
}Important: destroyCancellationToken becomes inaccessible during GameObject destruction. Cache or combine it as appropriate.
Rationale: Ensures async method chains are linked with Unity's GameObject lifecycle, preventing dangling tasks.
Use protected virtual intentionally for extension points in immutable codebases.
When developing code intended for Unity packages or shared libraries that will become immutable, use protected virtual for methods and properties that are intended as extension points. This promotes extensibility while keeping internal implementation details private or non-virtual.
// Good - extensible package code
public class BaseInteractable : MonoBehaviour
{
protected virtual void OnInteract()
{
PlayInteractSound();
TriggerInteractAnimation();
}
protected virtual float InteractionRange => 2.0f;
}
// Consumer can extend
public class CustomInteractable : BaseInteractable
{
protected override void OnInteract()
{
base.OnInteract();
ApplyCustomEffect();
}
protected override float InteractionRange => 5.0f;
}Use protected virtual for Unity lifecycle methods to signal base implementation exists.
When a base class implements Unity lifecycle methods, use protected virtual instead of private. This avoids the need for the new keyword in derived classes and makes it clear that the base implementation contains important logic.
// Good - base class signals overridable lifecycle
public class BaseController : MonoBehaviour
{
protected virtual void Awake()
{
InitializeCoreSystems();
}
protected virtual void Start()
{
RegisterWithManager();
}
}
// Derived class - clear override, no 'new' keyword needed
public class PlayerController : BaseController
{
protected override void Awake()
{
base.Awake(); // Clear signal that base has logic
InitializePlayerSystems();
}
protected override void Start()
{
base.Start();
LoadPlayerData();
}
}
// Avoid - private methods require 'new' keyword and hide base logic
public class BaseController : MonoBehaviour
{
private void Awake()
{
InitializeCoreSystems();
}
}
public class PlayerController : BaseController
{
private new void Awake() // 'new' keyword hides base implementation
{
// No clear indication base.Awake() exists
InitializePlayerSystems();
}
}Rationale: Makes inheritance chains explicit, prevents bugs from missed base calls, and ensures package code remains flexible for consumers.
Use UniTask for async/await in Unity:
public async UniTask<PlayerData> LoadPlayerAsync(CancellationToken cancellationToken)
{
await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: cancellationToken);
return new PlayerData();
}Use DOTween for tweening with UniTask integration:
public async UniTask AnimateHealthBarAsync(float targetValue, CancellationToken cancellationToken)
{
await _healthBarImage
.DOFillAmount(targetValue, 0.3f)
.SetEase(Ease.OutQuad)
.ToUniTask(cancellationToken: cancellationToken);
}Enable UniTask DOTween extension for proper async/await integration with tweens.
Use ServiceKit for dependency management via Service Locator pattern:
// Register services
ServiceLocator.Register<IHealthSystem>(new HealthSystem());
ServiceLocator.Register<IInventorySystem>(new InventorySystem());
// Retrieve services synchronously
var healthSystem = ServiceLocator.Get<IHealthSystem>();
// Retrieve services asynchronously (waits for registration)
var inventorySystem = await ServiceLocator.GetAsync<IInventorySystem>(cancellationToken);Why Service Locator over full IoC (Zenject/VContainer):
When to consider full IoC solutions:
Rationale: Service Locator provides sufficient dependency management for most Unity projects without the complexity and setup time of full IoC frameworks. For small teams and small to medium-sized projects, ServiceKit offers the right balance of simplicity and capability.
Use assembly definition files (.asmdef) to organize self-contained code modules.
Create separate assemblies for logical groupings such as:
Structure with Runtime and Editor separation:
Assets/ ├── ProjectName/ │ ├── Common/ │ │ ├── Content/ # Shared assets │ │ └── Source/ │ │ ├── Runtime/ │ │ │ ├── ProjectName.Common.Runtime.asmdef │ │ │ └── [runtime scripts] │ │ └── Editor/ │ │ ├── ProjectName.Common.Editor.asmdef │ │ └── [editor scripts] │ └── MiniGames/ │ ├── GameOne/ │ │ ├── Content/ # Feature-specific assets │ │ └── Source/ │ │ ├── Runtime/ │ │ │ ├── ProjectName.GameOne.Runtime.asmdef │ │ │ └── [runtime scripts] │ │ └── Editor/ │ │ ├── ProjectName.GameOne.Editor.asmdef │ │ └── [editor scripts] │ └── GameTwo/ │ ├── Content/ # Feature-specific assets │ └── Source/ │ ├── Runtime/ │ │ ├── ProjectName.GameTwo.Runtime.asmdef │ │ └── [runtime scripts] │ └── Editor/ │ ├── ProjectName.GameTwo.Editor.asmdef │ └── [editor scripts]
Assembly naming convention:
Benefits:
When to use:
Handling Circular Dependencies:
When two assemblies need to reference each other, introduce a third intermediary assembly that both can reference, similar to breaking circular references in architecture patterns.
// Problem: Circular dependency
// Player.Runtime.asmdef needs Enemy
// Enemy.Runtime.asmdef needs Player
// Solution: Create shared interface assembly
// Common/Source/Runtime/Interfaces/ (in Common.Runtime.asmdef)
public interface IDamageable
{
void TakeDamage(int amount);
}
public interface IAttacker
{
int AttackPower { get; }
}
// Player/Source/Runtime/ (Player.Runtime.asmdef references Common.Runtime)
public class Player : MonoBehaviour, IDamageable, IAttacker
{
public void TakeDamage(int amount)
{
// Implementation
}
public int AttackPower => 10;
}
// Enemy/Source/Runtime/ (Enemy.Runtime.asmdef references Common.Runtime)
public class Enemy : MonoBehaviour, IDamageable, IAttacker
{
public void TakeDamage(int amount)
{
// Implementation
}
public int AttackPower => 5;
}Rationale: Assembly definitions help maintain clean architecture and improve iteration times as projects scale. Separating Runtime and Editor assemblies ensures clean builds and prevents accidental editor dependencies in runtime code.
Maintain a clear, consistent folder structure that separates concerns and makes code discoverable.
Assets/ ├── ProjectName/ # Root namespace folder │ ├── Common/ # Shared code and assets │ ├── MiniGames/ # Individual features/minigames │ ├── Wrapper/ # Meta-game or shell │ └── ... ├── Configuration/ # Project settings, resources ├── Plugins/ # Third-party packages └── StreamingAssets/ # Runtime data files
Each feature or minigame should be self-contained with its own directory structure:
MiniGames/
└── FeatureName/ # e.g., Basketball, Inventory, DialogueSystem
├── Content/ # All assets for this feature
│ ├── Scenes/ # Unity scenes
│ ├── Prefabs/ # Prefabs specific to this feature
│ ├── Materials/ # Materials
│ ├── Textures/ # Textures and sprites
│ ├── Audio/ # Audio files
│ ├── Models/ # 3D models
│ └── Animations/ # Animation clips and controllers
├── Source/ # All code for this feature
│ ├── Runtime/ # Runtime scripts
│ │ ├── FeatureName.Runtime.asmdef
│ │ ├── Controllers/
│ │ ├── Services/
│ │ ├── UI/
│ │ └── ...
│ └── Editor/ # Editor-only scripts
│ └── FeatureName.Editor.asmdef
├── README.md # Feature documentation
└── IMPROVEMENTS.md # Known issues and future work
Common/
├── Content/ # Shared assets
│ ├── Prefabs/ # Reusable prefabs
│ ├── Materials/ # Shared materials
│ ├── Fonts/ # Project fonts
│ ├── Textures/ # Shared textures
│ └── Models/ # Shared models
└── Source/ # Shared code
├── Runtime/ # Shared runtime code
│ ├── Common.Runtime.asmdef
│ ├── Audio/
│ ├── UI/
│ ├── Extensions/
│ ├── Utils/
│ └── ...
└── Editor/ # Shared editor code
└── Common.Editor.asmdef
Each feature should have separate assemblies for Runtime and Editor code:
Source/
├── Runtime/
│ ├── IGB.BoxParty.Basketball.Runtime.asmdef
│ └── [runtime scripts]
└── Editor/
├── IGB.BoxParty.Basketball.Editor.asmdef (references Runtime)
└── [editor scripts]
Benefits:
✓ Source/ ✓ Prefabs/ ✓ PlayerControllers/ ✗ source/ ✗ stuff/ ✗ misc/
✓ MainMenu.unity ✓ BasketballMinigame.unity ✓ Level01_Forest.unity ✗ scene1.unity ✗ test.unity ✗ Untitled.unity
✓ PlayerCharacter.prefab ✓ InventoryUI.prefab ✓ AudioService.prefab ✓ EnemySpawner.prefab ✗ player.prefab ✗ Prefab1.prefab
Materials: ✓ PlayerSkin_Default.mat ✓ Glass_Transparent.mat ✓ Metal_Brushed.mat Textures: ✓ PlayerSkin_Diffuse.png ✓ PlayerSkin_Normal.png ✓ UI_Button_Idle.png
Use PascalCase for all Unity assets, directories, and GameObjects. Avoid spaces everywhere.
This is one of those areas where the Unity community is deeply divided. You'll see three camps:
I'm firmly in the PascalCase camp across the board—assets, folders, and GameObjects. Here's why:
I learned this lesson the hard way. We had a project where half the team used spaces in asset names because "it looks cleaner in the Unity Editor." Everything seemed fine until we needed to:
// With spaces - prone to errors and encoding issues
var prefab = Resources.Load("Player Controllers/Heavy Armor Player");
var icon = AssetDatabase.LoadAssetAtPath<Sprite>("Assets/UI Icons/Health Icon Large.png");
// Without spaces - clean and predictable
var prefab = Resources.Load("PlayerControllers/HeavyArmorPlayer");
var icon = AssetDatabase.LoadAssetAtPath<Sprite>("Assets/UIIcons/HealthIconLarge.png");The "But Unity Uses Spaces" Argument:
Yes, Unity's own assets often use spaces. The "Standard Assets" package is full of them. But here's the thing—Unity can get away with it because they control the entire pipeline. When you're working in a team, using third-party tools, or building automation, those spaces will eventually bite you.
General Assets:
✓ PlayerCharacter.prefab (PascalCase, no spaces) ✓ HealthPickup.prefab ✓ MainMenu.unity ✓ ButtonClick.wav ✓ Button_Normal.png (underscore for state separation) ✓ Button_Hover.png (underscore for state separation) ✓ Button_Pressed.png (underscore for state separation) ✗ Player Character.prefab (spaces cause path issues) ✗ playerCharacter.prefab (inconsistent with folder naming) ✗ player_character.prefab (snake_case for the main name)
Texture Naming Convention:
Include the texture type as a suffix for clarity:
✓ PlayerSkin_Diffuse.png ✓ PlayerSkin_Normal.png ✓ PlayerSkin_Specular.png ✓ PlayerSkin_Height.png ✓ PlayerSkin_Occlusion.png ✓ PlayerSkin_Emission.png
Animation Clips:
✓ Player_Idle.anim ✓ Player_Run.anim ✓ Player_Jump_Start.anim ✓ Player_Jump_Loop.anim ✓ Player_Jump_Land.anim
Audio Files:
✓ Footstep_Concrete_01.wav ✓ Footstep_Concrete_02.wav ✓ UI_ButtonClick.wav ✓ Music_MainTheme.ogg ✓ Ambient_Forest.ogg
Variant Naming:
When you have multiple versions of the same asset:
✓ Enemy_Goblin_Red.prefab ✓ Enemy_Goblin_Blue.prefab ✓ Enemy_Goblin_Elite.prefab ✓ Sword_Iron_Tier1.prefab ✓ Sword_Iron_Tier2.prefab ✓ Sword_Steel_Tier1.prefab
Underscores are perfectly fine when separating states, variants, or descriptors from the main asset name.
While the main asset name should be PascalCase, use underscores to separate:
The key distinction: the underscore separates the what from the which:
This is different from snake_case naming like player_character or health_pickup, where underscores are used within the primary name itself. The underscore should be a delimiter between the asset and its state/variant, not a word separator within the name.
Different platforms and tools have different tolerances for spaces:
| Context | Spaces Work? | Notes |
|---|---|---|
| Unity Editor | ✅ Yes | Displays fine |
| Resources.Load() | ⚠️ Mostly | Requires exact string match |
| Addressables | ⚠️ Mostly | Can cause issues with addresses |
| Git/Command Line | ❌ Often problematic | Requires quotes/escaping |
| Build Scripts | ❌ Problematic | Spaces break arguments |
| Web Deployment | ❌ Problematic | URL encoding issues |
| Asset Bundles | ⚠️ Mostly | Can complicate manifest parsing |
Early in my Unity journey, I used spaces everywhere because it "looked professional" in the Inspector. Then came the project where we needed to automate our build pipeline. I spent two days debugging why certain assets weren't being included in builds—turned out the build script was choking on spaces in filenames.
After that experience, I switched to PascalCase everywhere and never looked back. Yes, PlayerHealthManager is slightly less readable in the Project window than Player Health Manager, but the technical benefits far outweigh the aesthetic cost.
If your team is already using spaces:
If you're starting fresh:
Apply PascalCase to GameObjects too—in for a penny, in for a pound.
I'll admit, I'm slightly more flexible about spaces in GameObject names than I am with assets. GameObjects don't get referenced in file paths, and the Hierarchy is often the domain of level designers, artists, and other non-programmers. But here's the thing—once you've committed to PascalCase everywhere else, why introduce inconsistency?
Hierarchy (Good - PascalCase): MainCamera PlayerCharacter ├── Model ├── Collider ├── HealthBar │ ├── Background │ ├── FillBar │ └── BorderFrame EnemySpawner ├── SpawnPoint01 ├── SpawnPoint02 └── SpawnPoint03 Hierarchy (Avoid - Mixed conventions): Main Camera // Space playerCharacter // camelCase ├── Model // PascalCase ├── collider // lowercase ├── Health Bar // Space enemy_spawner // snake_case
The Case for Consistency:
When everything in your project follows the same naming convention:
The Designer Argument:
"But designers aren't programmers! They want readable names!"
I get it. And if your team's designers are adamant about spaces, you can compromise on GameObjects. But in my experience, designers quickly adapt to PascalCase, especially when they see the benefits:
Directories follow the same PascalCase rule—no exceptions.
Your folder structure sets the tone for the entire project. When developers see consistently named folders, they're more likely to follow suit with their assets:
Assets/ ├── ProjectName/ │ ├── Common/ │ │ ├── Source/ │ │ ├── Prefabs/ │ │ └── Materials/ │ ├── Player/ │ │ ├── Source/ │ │ ├── Prefabs/ │ │ └── Animations/ │ └── Enemies/ │ ├── Goblin/ │ ├── Orc/ │ └── Dragon/ NOT: ├── Project Name/ // Spaces ├── common/ // lowercase ├── player_stuff/ // snake_case └── Misc Assets/ // Inconsistent
The benefits compound with directories:
There's something deeply satisfying about opening a Unity project and seeing perfectly consistent naming throughout. When I see a hierarchy like this:
Canvas
├── MainMenu
│ ├── TitleText
│ ├── PlayButton
│ ├── OptionsButton
│ └── QuitButton
├── HUD
│ ├── HealthDisplay
│ ├── AmmoCounter
│ └── ScoreText
└── PauseMenu
├── ResumeButton
└── MainMenuButton
...versus this:
Canvas
├── Main Menu
│ ├── title_text
│ ├── Play Button
│ ├── optionsBtn
│ └── Quit_Button
├── HUD
│ ├── Health Display
│ ├── ammo-counter
│ └── Score Text
└── pause_menu
├── ResumeButton
└── main menu button
The first one just feels right. It's not crucially important—your game won't fail because of inconsistent naming—but that sense of calm and cleanliness you get from well-organized, consistently named hierarchies? That translates to clearer thinking, fewer mistakes, and a more maintainable project.
It's like making your bed in the morning. Will the world end if you don't? No. But starting your day with that small act of order sets a tone. Similarly, maintaining consistent naming throughout your Unity project sets a tone of professionalism and care that permeates the entire development process.
My recommendation: Use PascalCase without spaces for everything—assets, directories, and GameObjects. It's technically safer, more compatible with tools and scripts, and maintains consistency throughout your project. The slight reduction in Inspector readability is a small price to pay for the technical benefits and that satisfying sense of order.
But remember—if your team has an established convention, follow it. A consistent "wrong" approach is better than an inconsistent "right" one. That said, if you're in a position to influence the convention, advocate for PascalCase everywhere. Your future self (and your teammates) will thank you when everything just works without escape characters, quotes, or encoding issues.
Each self-contained feature should include:
README.md - Feature overview and usage:
# Basketball Minigame
## Overview
Brief description of the feature/minigame
## Setup
How to use/integrate this feature
## Dependencies
What this feature requires
## Key Components
Main classes and their purposesIMPROVEMENTS.md - Technical debt and future work:
# Improvements & Known Issues
## Known Issues
- Issue description and workaround
## Future Improvements
- Potential enhancements
- Performance optimizations neededSelf-Contained Features:
Clear Separation:
Avoid:
Configuration & Settings:
Prefer self-documenting code where names and methods contain explanation.
// Good - code explains itself
public void ApplyFallDamage()
{
if (!HasFallenFarEnough()) return;
int damage = CalculateFallDamage();
_healthSystem.TakeDamage(damage);
}
// Avoid - unnecessary comments
public void ApplyFallDamage()
{
// Check if fallen far enough
if (!HasFallenFarEnough()) return;
// Calculate the damage
int damage = CalculateFallDamage();
// Apply damage to health system
_healthSystem.TakeDamage(damage);
}Discourage over-commenting and needless commenting. Comment when:
// Good - explains non-obvious Unity behavior
// Unity's destroyCancellationToken becomes inaccessible during OnDestroy,
// so we cache it during Awake to ensure cleanup can cancel properly
private CancellationToken _cachedDestroyToken;
private void Awake()
{
_cachedDestroyToken = this.destroyCancellationToken;
}Use XML comments for public APIs and complex methods:
/// <summary>
/// Calculates damage based on fall distance using Unity's physics system.
/// </summary>
/// <param name="fallDistance">The distance fallen in meters.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The calculated damage amount.</returns>
public async UniTask<int> CalculateFallDamageAsync(float fallDistance, CancellationToken cancellationToken)
{
// Implementation
}We follow a branching strategy similar to Git Flow with specific adaptations for our workflow.
Main Branches:
Supporting Branches:
All branch names must be lowercase.
# Feature branches (use shorthand 'feat/')
feat/player-inventory
feat/dialogue-system
feat/jira-123-enemy-ai
# Release branches (semantic versioning)
release/1.0.0
release/1.2.1
release/2.0.0-betaWhen using project management tools (e.g., Jira):
Create feature branch from develop:
git checkout develop
git pull origin develop
git checkout -b feat/player-inventoryWork on feature with regular commits
Before creating PR, bring feature inline with develop:
git checkout develop
git pull origin develop
git checkout feat/player-inventory
git merge developWhy merge instead of rebase:
Create PR from feature branch to develop
Squash and merge PR into develop:
Create release branch from develop:
git checkout develop
git pull origin develop
git checkout -b release/1.0.1Perform final testing and bug fixes on release branch
Tag the release branch commit:
git checkout release/1.0.1
git tag -a v1.0.1 -m "Release version 1.0.1"
git push origin release/1.0.1 --tags⚠️ Important: Tag the release branch BEFORE merging
Tag the release branch commit first, then merge that tagged commit into main and develop. This ensures:
Create PR from release branch to main
Merge tagged release to main:
git checkout main
git merge release/1.0.1
git push origin main⚠️ Critical: Use regular merge (NOT squash merge) for release branches
When merging release branches, always use a regular merge instead of squash merge:
# ❌ WRONG - Creates orphaned tag
git tag v1.0.1 # Tag on release branch
git checkout main
git merge --squash release/1.0.1 # Creates NEW commit
git commit -m "Release 1.0.1"
# Tagged commit from release branch is orphaned - not in main's history!
# ✅ CORRECT - Tag travels with merge
git tag v1.0.1 # Tag on release branch
git checkout main
git merge release/1.0.1 # Regular merge preserves commits
# Tagged commit is now in main's historyMerge release changes back to develop:
git checkout develop
git merge release/1.0.1
git push origin developThe tagged commit is now in the history of main, develop, and the release branch.
Delete release branch:
git branch -d release/1.0.1
git push origin --delete release/1.0.1When a feature merged to develop is later requested for inclusion in an existing release branch (e.g., priority changed during release cycle):
Ensure feature is merged to develop first:
# Feature should be squash-merged to develop following normal workflow
# This creates a single commit representing the entire featureCherry-pick the squashed commit to release branch:
git checkout develop
git log --oneline # Find feature commit: abc1234 "Add player statistics tracking"
git checkout release/1.0.1
git cherry-pick abc1234
git push origin release/1.0.1If cherry-pick has conflicts:
git cherry-pick --continue
git push origin release/1.0.1For features requiring significant adaptation:
git checkout release/1.0.1
git checkout -b feat/statistics-for-release
# Make necessary adaptations
# Create PR to release/1.0.1After release completes:
Benefit of squash and merge for this workflow:
This scenario demonstrates a key advantage of squash-and-merge: Each feature is a single, atomic commit that can be easily cherry-picked to other branches. Without squash-and-merge, you'd need to identify and cherry-pick multiple commits (or cherry-pick a range), which is error-prone and may include unintended changes.
# With squash and merge (simple)
git cherry-pick abc1234 # One commit, entire feature
# Without squash and merge (complex)
git cherry-pick def5678 ghi9012 jkl3456 mno7890 # Which commits? Did I get them all?Hotfix strategy depends on whether the fix is for production or pre-production code, and how far ahead the develop branch has progressed.
When production bug applies to develop (develop hasn't diverged significantly):
Create hotfix branch from main:
git checkout main
git pull origin main
git checkout -b hotfix/1.0.2-critical-bugFix and test on hotfix branch
Tag the hotfix branch commit:
git checkout hotfix/1.0.2-critical-bug
git tag -a v1.0.2 -m "Hotfix: Critical bug fix"
git push origin hotfix/1.0.2-critical-bug --tags⚠️ Important: Tag the hotfix branch BEFORE merging, just like with release branches. This ensures the tagged commit travels into both main and develop's history.
Merge tagged hotfix to main:
git checkout main
git merge hotfix/1.0.2-critical-bug
git push origin main⚠️ Critical: Use regular merge (NOT squash merge) for hotfix branches that will be tagged. See the tagged branch explanation in the Release Workflow section above for details on why squash merge orphans tagged commits.
Merge to develop:
git checkout develop
git merge hotfix/1.0.2-critical-bug
git push origin developThe tagged commit is now in the history of main, develop, and the hotfix branch.
Delete hotfix branch:
git branch -d hotfix/1.0.2-critical-bug
git push origin --delete hotfix/1.0.2-critical-bugWhen production bug applies but develop has diverged significantly:
Follow steps 1-4 above (create, fix, tag hotfix branch, merge to main)
Cherry-pick the fix to develop:
git checkout develop
git cherry-pick <commit-hash>
git push origin developIf cherry-pick conflicts occur:
git checkout develop
git checkout -b fix/critical-bug-for-develop
# Manually apply fix appropriate for develop's context
# Create PR to developFor bugs only in develop (not yet in production):
git checkout develop
git pull origin develop
git checkout -b fix/memory-leak
# Fix and test
# Create PR to develop| Scenario | Branch From | Branch Type | Merge To | Then Merge To |
|---|---|---|---|---|
| Production bug, develop similar | main | hotfix/ | main + tag | develop (merge) |
| Production bug, develop diverged | main | hotfix/ | main + tag | develop (cherry-pick) |
| Pre-production bug only | develop | fix/ | develop only | N/A |
Rationale:
⚠️ Key variations from standard Git Flow:
| Aspect | Git Flow | This Standard |
|---|---|---|
| Feature PR merging | Regular merge | Squash and merge |
| Feature branch sync | Merge from develop | Same (merge from develop) |
| Hotfix branches | Always from main to main+develop | Context-dependent (merge or cherry-pick based on divergence) |
| Branch naming | feature/, hotfix/, release/ | Shorthand: feat/, hotfix/, release/, fix/ |
| Release branch merging | Merged to both main and develop | Regular merge to main and develop (never squash - preserves tag references) |
Rationale for squash and merge:
Exception for large features:
For particularly large or complex features with multiple sub-components, consider using a regular merge instead of squash and merge to preserve:
# Large feature with meaningful sub-commits
git checkout develop
git merge --no-ff feat/large-multiplayer-system
# Preserves commits like:
# - Add lobby system
# - Add matchmaking service
# - Add player synchronization
# - Add voice chat integrationUse judgment: squash for typical features, regular merge for large multi-part features where sub-feature detail adds value.
Exception for tagged branches (releases and hotfixes):
Never use squash merge when merging branches that have been tagged. This is the most critical exception to the squash-merge preference.
Correct workflow:
What happens with squash merge (incorrect):
Impact of orphaned tags:
This applies to:
# ❌ NEVER do this for branches that have been tagged
git checkout release/1.0.1
git tag v1.0.1 # Tag on release branch
git checkout main
git merge --squash release/1.0.1 # Creates NEW commit!
git commit -m "Release 1.0.1"
# Tagged commit is orphaned - only exists on release/1.0.1, not in main!
# ✅ ALWAYS do this for branches that have been tagged
git checkout release/1.0.1
git tag v1.0.1 # Tag on release branch
git checkout main
git merge release/1.0.1 # Regular merge preserves commits
# Tagged commit now exists in main's history
git checkout develop
git merge release/1.0.1 # Regular merge preserves commits
# Tagged commit now exists in develop's history tooWrite clear, descriptive commit messages:
# Good - descriptive and concise
git commit -m "Add player inventory system with drag-and-drop support"
git commit -m "Fix memory leak in enemy spawner (JIRA-123)"
git commit -m "Refactor health system to use events instead of polling"
# Avoid - vague or uninformative
git commit -m "Fixed stuff"
git commit -m "WIP"
git commit -m "Updates"Format:
AI tools (LLMs, code assistants) are valuable productivity multipliers when used responsibly. These guidelines help maintain code quality while leveraging AI capabilities.
Developer Responsibility
AI is a tool, not a replacement for understanding:
Code Quality Standards Apply
AI-generated code must meet all coding standards:
Security & Privacy
Protect proprietary and sensitive information:
Provide Context
Help AI understand your project:
Testing AI-Generated Code
Always verify AI output:
When AI Shines
AI is particularly effective for:
When to Be Cautious
Exercise extra care with AI for:
Tool Agnostic Approach
Use whatever AI tool works best for your workflow:
AI Configuration Files
Keep AI-specific configuration local:
# AI Assistant Configuration
.claude/
.cursor/
.copilot/
.aider/
**/CLAUDE_TEMP.md
**/.ai-context/Project Context Files
Maintain shared context files (DO commit these):
Effective AI Prompting
Good prompt:
"Refactor this Unity MonoBehaviour to use async/await with UniTask instead of
coroutines. Ensure cancellation tokens are linked to destroyCancellationToken.
Follow the never nester principle with early returns."
Poor prompt:
"Make this code better"Code Review with AI
Use AI as an additional reviewer:
Iterative Refinement
Work with AI iteratively:
✅ DO:
✅ DON'T:
Remember: AI is a powerful assistant, but you are the developer. Your understanding, judgment, and responsibility for the code remain paramount.
Ask questions, but earn the right to ask them through effort.
This section might seem out of place in a coding standards document, but I've come to realize it's just as important as any formatting rule. Learning when and how to ask questions is a critical skill that affects team productivity and your own growth as a developer.
As a senior/lead developer, I genuinely want people to ask me questions. I mean it. There's nothing worse than someone struggling in silence for days when a five-minute conversation could have unblocked them. But here's the thing I've learned from being on both sides of this equation: questions have a cost.
When someone asks me a question, it's not just the time to answer—it's the mental context switch. I might be deep in debugging something complex, and switching gears to answer a question can cost me 15-30 minutes of rebuilt mental state after you leave. I'm happy to pay that cost, but I need to know you've tried to minimize it.
Before asking a question, spend at least 15 minutes trying to answer it yourself. This isn't arbitrary—it's about demonstrating respect for others' time while maximizing your own learning. Here's what those 15 minutes should look like:
First 5 minutes - Check the obvious:
Next 5 minutes - Dig deeper:
Final 5 minutes - Prepare your question:
If you've done the work and you're still stuck, here's how to make the interruption worthwhile for both of us:
// Bad question:
"The user system isn't working. Can you help?"
// Good question:
"I'm getting a null reference exception in UserManager.cs:45 when
logging in with a new user. I've verified the user object exists,
the database connection is valid, and the same code works for
existing users. I suspect it's related to the initialization order
but I can't find where new users get their default settings.
Could you point me toward where that happens?"The good question shows:
Not all questions deserve 15 minutes of struggle. Skip the research for:
These questions often have answers that you couldn't derive on your own, and struggling with them wastes everyone's time.
Early in your career, you might need to ask more questions—that's expected and encouraged. But as you grow, the questions should evolve:
Junior questions (perfectly valid):
Senior questions (what you grow toward):
If you're the senior being asked questions:
Here's something I've noticed: the people who are most hesitant to ask questions are often the ones whose questions I most want to hear. They've done the work, they're thoughtful, and their questions often reveal issues I hadn't considered.
Meanwhile, the people who ask questions immediately without any effort? They're usually the same ones who won't remember the answer because they haven't struggled enough to create the mental framework to hold the knowledge.
I used to pride myself on never asking questions, figuring everything out on my own. It felt like strength. Now I realize it was actually a weakness—I wasted days on problems that could have been solved in minutes. The real strength is knowing when you've hit the point of diminishing returns on solo effort.
These days, I follow my own 15-minute rule religiously. Sometimes I solve it in minute 14 and feel brilliant. Sometimes I ask the question and discover there was no way I could have known the answer. Both outcomes are victories.
The key insight: Asking good questions is a skill that shows respect for everyone's time, including your own.
Explore other perspectives to form your own opinions.
These coding standards represent my personal journey and opinions, but there's immense value in seeing how others approach these same challenges. Here are resources that have influenced my thinking or offer different perspectives worth considering:
Microsoft's C# Coding Conventions
Unity's Official C# Style Guide
SamuelAsherRivello/unity-project-template
thomasjacobsen-unity/Unity-Code-Style-Guide
justinwasilenko/Unity-Style-Guide
MuhammadFaizanKhan/UnityCSharpCodingStandards
Content Creators Who Shape Unity Standards:
I include these not because they're "better" or "worse," but because:
Some of these standards directly contradict mine (regions, abbreviated names, etc.), and that's valuable. The disagreements are where the interesting discussions happen.
After reviewing these resources, you might:
The important thing isn't which standards you choose, but that you choose consciously and document your decisions. Your future self (and your team) will thank you.
Remember: The best coding standard is the one your team will actually follow.
These standards prioritize:
When Microsoft standards conflict with team preferences, this document's guidelines take precedence, with conflicts clearly marked with ⚠️.
MIT License
Copyright (c) 2025 Paul Stamp
Originally developed for Nonatomic
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
| Back | FazBrowse Home | New Git URL |