11.1 - C# Coding Conventions and Best Practices
Writing clean, readable, and maintainable code is just as important as making it work correctly. In this section, we'll explore the coding conventions and best practices that professional C# developers follow, with a special focus on game development scenarios.
Why Coding Conventions Matter
Coding conventions provide a consistent framework for writing code. They're important because:
- Readability: Code is read far more often than it's written. Clear conventions make your code easier to understand.
- Maintainability: Following standards makes it easier for you (or others) to maintain and update code later.
- Collaboration: When working in a team, consistent conventions help everyone understand each other's code.
- Bug Prevention: Many conventions exist to help prevent common bugs and issues.
- Professional Development: Learning to follow industry standards prepares you for professional development environments.
Naming Conventions
General Naming Guidelines
- Use meaningful, descriptive names that clearly communicate purpose
- Prioritize clarity over brevity (but avoid unnecessarily long names)
- Don't use Hungarian notation (prefixing variable names with type information)
- Avoid abbreviations unless they're widely understood
// Good naming
int playerHealth;
float enemyMovementSpeed;
bool isGamePaused;
// Poor naming
int ph; // Too short, unclear
float enmyMvSpd; // Unnecessary abbreviations
bool gp; // Unclear purpose
Case Conventions
C# uses different case styles for different elements:
Element | Case Style | Example |
---|---|---|
Namespace | PascalCase | GameSystems.Inventory |
Class/Struct | PascalCase | PlayerController |
Interface | PascalCase with 'I' prefix | IInteractable |
Method | PascalCase | CalculateDamage() |
Property | PascalCase | PlayerHealth |
Field (private) | camelCase with underscore prefix | _maxHealth |
Field (public) | PascalCase | MaxHealth |
Parameters | camelCase | playerDamage |
Local variables | camelCase | temporaryHealth |
Constants | PascalCase | MaxPlayerCount |
Unity-Specific Naming
In Unity projects, additional conventions often apply:
-
MonoBehaviour classes: Named after their purpose, usually ending with a descriptor like "Controller", "Manager", or "System"
public class PlayerController : MonoBehaviour { }
public class InventorySystem : MonoBehaviour { } -
Serialized fields: Use descriptive names that will make sense in the Unity Inspector
[SerializeField] private float _movementSpeed = 5f;
Code Organization
File Organization
- One class per file (with exceptions for small, closely related classes)
- Filename should match the primary class name
- Group related files in appropriate folders/namespaces
Class Organization
Organize your class members in a consistent order:
public class GameCharacter
{
// 1. Constant fields
public const int MaxHealth = 100;
// 2. Static fields
private static int _characterCount = 0;
// 3. Fields (private)
private int _currentHealth;
private string _characterName;
// 4. Properties
public int CurrentHealth
{
get { return _currentHealth; }
private set { _currentHealth = value; }
}
// 5. Constructors
public GameCharacter(string name)
{
_characterName = name;
_currentHealth = MaxHealth;
_characterCount++;
}
// 6. Methods (grouped by functionality)
public void TakeDamage(int amount)
{
_currentHealth -= amount;
if (_currentHealth < 0)
_currentHealth = 0;
}
public void Heal(int amount)
{
_currentHealth += amount;
if (_currentHealth > MaxHealth)
_currentHealth = MaxHealth;
}
// 7. Nested types (if any)
public enum CharacterState
{
Idle,
Moving,
Attacking,
Damaged
}
}
Unity MonoBehaviour Organization
For Unity scripts, a common organization pattern is:
public class PlayerController : MonoBehaviour
{
// 1. Serialized fields (inspector variables)
[SerializeField] private float _movementSpeed = 5f;
[SerializeField] private GameObject _weaponPrefab;
// 2. Private fields
private Rigidbody _rigidbody;
private bool _isGrounded;
// 3. Properties
public bool IsGrounded => _isGrounded;
// 4. Unity lifecycle methods
private void Awake()
{
_rigidbody = GetComponent<Rigidbody>();
}
private void Start()
{
InitializeWeapon();
}
private void Update()
{
HandleInput();
}
private void FixedUpdate()
{
HandleMovement();
}
// 5. Custom methods (grouped by functionality)
private void HandleInput()
{
// Input handling logic
}
private void HandleMovement()
{
// Movement logic using _rigidbody
}
private void InitializeWeapon()
{
// Weapon initialization logic
}
// 6. Public methods that might be called by other scripts
public void TakeDamage(int amount)
{
// Damage handling logic
}
}
Formatting Conventions
Indentation and Spacing
- Use 4 spaces for indentation (or tabs set to 4 spaces)
- Add a space before and after operators
- Add a space after commas in argument lists
- No space between a method name and its opening parenthesis
- One statement per line
// Good formatting
int damage = baseDamage * damageMultiplier;
CalculateDamage(playerLevel, weaponStrength, criticalHit);
// Poor formatting
int damage=baseDamage*damageMultiplier;
CalculateDamage ( playerLevel,weaponStrength,criticalHit );
Braces
- Opening braces on the same line as the statement
- Closing braces on a new line
- Always use braces for control statements, even for single-line blocks
// Recommended brace style
if (playerHealth <= 0) {
GameOver();
RestartLevel();
}
// Always use braces, even for single statements
if (isGamePaused) {
ResumeGame();
}
C# Coding Best Practices
1. Use Properties Instead of Public Fields
Properties allow you to control access and add validation logic:
// Avoid this
public float movementSpeed = 5f;
// Prefer this
private float _movementSpeed = 5f;
public float MovementSpeed {
get { return _movementSpeed; }
set {
// Validation logic
if (value >= 0) {
_movementSpeed = value;
}
}
}
// Or use auto-properties when no additional logic is needed
public float MovementSpeed { get; private set; } = 5f;
2. Prefer Composition Over Inheritance
Composition (building objects from smaller, reusable components) is often more flexible than deep inheritance hierarchies:
// Instead of a complex inheritance tree like:
// GameObject -> Character -> Player -> Wizard
// Consider composition:
public class Player
{
private HealthSystem _healthSystem;
private MovementController _movementController;
private InventorySystem _inventory;
private SpellCaster _spellCaster; // For wizard functionality
public Player(PlayerConfig config)
{
_healthSystem = new HealthSystem(config.BaseHealth);
_movementController = new MovementController(config.MovementSpeed);
_inventory = new InventorySystem(config.InventorySlots);
if (config.PlayerClass == PlayerClass.Wizard) {
_spellCaster = new SpellCaster(config.ManaPool);
}
}
}
3. Keep Methods Short and Focused
Each method should do one thing and do it well:
// Avoid large, complex methods
private void Update()
{
// 100+ lines of mixed logic for input, movement, combat, etc.
}
// Prefer smaller, focused methods
private void Update()
{
HandleInput();
UpdateCooldowns();
CheckEnvironmentalEffects();
}
private void FixedUpdate()
{
ApplyMovement();
ApplyPhysicsInteractions();
}
private void HandleInput()
{
// 10-15 lines focused only on input handling
}
4. Use Enums for Related Constants
Enums improve code readability and type safety:
// Instead of:
public const int StateIdle = 0;
public const int StateWalking = 1;
public const int StateRunning = 2;
// Use an enum:
public enum CharacterState
{
Idle,
Walking,
Running
}
// Usage:
private CharacterState _currentState = CharacterState.Idle;
public void SetState(CharacterState newState)
{
_currentState = newState;
}
5. Avoid Magic Numbers and Strings
Define constants for values that have meaning:
// Avoid:
if (playerHealth < 20) {
ShowLowHealthWarning();
}
// Prefer:
private const int LowHealthThreshold = 20;
if (playerHealth < LowHealthThreshold) {
ShowLowHealthWarning();
}
6. Use Nullable Types Appropriately
Nullable types can express the absence of a value:
// For optional components or references
private GameObject _currentTarget = null;
// For values that might not be set
private int? _highScore = null;
public void SetHighScore(int score)
{
if (!_highScore.HasValue || score > _highScore.Value) {
_highScore = score;
}
}
7. Prefer String Interpolation for String Formatting
String interpolation makes string construction more readable:
// Instead of:
string message = "Player " + playerName + " scored " + score + " points!";
// Or:
string message = string.Format("Player {0} scored {1} points!", playerName, score);
// Use string interpolation:
string message = $"Player {playerName} scored {score} points!";
8. Use Object Initializers
Object initializers make object creation more concise:
// Instead of:
PlayerStats stats = new PlayerStats();
stats.Strength = 10;
stats.Dexterity = 8;
stats.Intelligence = 12;
// Use an object initializer:
PlayerStats stats = new PlayerStats {
Strength = 10,
Dexterity = 8,
Intelligence = 12
};
Game Development Specific Best Practices
1. Optimize Memory Allocation
Excessive garbage collection can cause performance hitches in games:
// Avoid creating new objects every frame
private void Update()
{
// Bad: Creates a new Vector3 every frame
transform.position = new Vector3(transform.position.x + 0.1f, transform.position.y, transform.position.z);
// Better: Modify existing Vector3
Vector3 position = transform.position;
position.x += 0.1f;
transform.position = position;
}
// Reuse collections instead of creating new ones
private List<Enemy> _enemiesInRange = new List<Enemy>();
private void FindEnemiesInRange()
{
// Clear and reuse the list instead of creating a new one
_enemiesInRange.Clear();
// Add enemies to the existing list
foreach (Enemy enemy in AllEnemies) {
if (Vector3.Distance(transform.position, enemy.transform.position) < detectionRange) {
_enemiesInRange.Add(enemy);
}
}
}
2. Use Appropriate Access Modifiers
Restrict access to class members appropriately:
// Public only when necessary
public int PlayerHealth { get; private set; } // Read-only from outside
// Private for implementation details
private void CalculateDamageModifiers() { }
// Protected for inheritance
protected virtual void OnDeath() { }
3. Implement Proper Error Handling
Robust error handling prevents crashes and helps with debugging:
public void LoadGameData(string filePath)
{
try {
if (string.IsNullOrEmpty(filePath)) {
throw new ArgumentException("File path cannot be empty");
}
if (!File.Exists(filePath)) {
Debug.LogWarning($"Save file not found at {filePath}. Creating new game data.");
CreateNewGameData();
return;
}
string json = File.ReadAllText(filePath);
GameData data = JsonUtility.FromJson<GameData>(json);
ApplyGameData(data);
}
catch (Exception ex) {
Debug.LogError($"Failed to load game data: {ex.Message}");
// Fallback to default data
CreateNewGameData();
}
}
4. Use Comments Effectively
Write comments that explain "why" rather than "what":
// Avoid obvious comments
// Set health to 100
health = 100;
// Better comments explain the reasoning
// Reset health to full when player reaches a checkpoint
// to provide a more forgiving gameplay experience
health = maxHealth;
5. Use Regions Sparingly
Regions can help organize large files, but overusing them can hide poor code organization:
#region Input Handling
// Input-related methods
private void HandleMovementInput() { }
private void HandleActionInput() { }
#endregion
#region Combat System
// Combat-related methods
private void CalculateDamage() { }
private void ApplyDamage() { }
#endregion
Practical Example: Refactoring for Best Practices
Let's look at a before-and-after example of refactoring code to follow best practices:
Before Refactoring
public class Player : MonoBehaviour
{
// Public fields exposed to inspector
public float speed = 5;
public int hp = 100;
public int maxHp = 100;
public GameObject bulletPrefab;
public float fireRate = 0.5f;
float lastFireTime;
void Update()
{
// Movement
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
transform.Translate(new Vector3(h, 0, v) * speed * Time.deltaTime);
// Shooting
if (Input.GetButton("Fire1"))
{
if (Time.time > lastFireTime + fireRate)
{
GameObject bullet = Instantiate(bulletPrefab, transform.position, transform.rotation);
bullet.GetComponent<Rigidbody>().AddForce(transform.forward * 1000);
lastFireTime = Time.time;
}
}
}
// Take damage
public void Damage(int amount)
{
hp -= amount;
if (hp <= 0)
{
hp = 0;
// Game over
Debug.Log("Player died");
Destroy(gameObject);
}
}
// Heal player
public void Heal(int amount)
{
hp += amount;
if (hp > maxHp)
hp = maxHp;
}
}
After Refactoring
public class PlayerController : MonoBehaviour
{
[Header("Movement")]
[SerializeField] private float _movementSpeed = 5f;
[Header("Health")]
[SerializeField] private int _maxHealth = 100;
[Header("Combat")]
[SerializeField] private GameObject _bulletPrefab;
[SerializeField] private float _fireRate = 0.5f;
[SerializeField] private float _bulletForce = 1000f;
[SerializeField] private Transform _firePoint;
private int _currentHealth;
private float _lastFireTime;
private Rigidbody _rigidbody;
// Properties with appropriate access modifiers
public int CurrentHealth => _currentHealth;
public int MaxHealth => _maxHealth;
public bool IsAlive => _currentHealth > 0;
private void Awake()
{
_rigidbody = GetComponent<Rigidbody>();
}
private void Start()
{
InitializePlayer();
}
private void Update()
{
HandleInput();
}
private void FixedUpdate()
{
ApplyMovement();
}
private void InitializePlayer()
{
_currentHealth = _maxHealth;
}
private void HandleInput()
{
// Shooting input handled separately from movement
if (Input.GetButton("Fire1"))
{
TryShoot();
}
}
private void ApplyMovement()
{
float horizontalInput = Input.GetAxis("Horizontal");
float verticalInput = Input.GetAxis("Vertical");
Vector3 movement = new Vector3(horizontalInput, 0f, verticalInput).normalized;
_rigidbody.MovePosition(transform.position + movement * _movementSpeed * Time.fixedDeltaTime);
}
private void TryShoot()
{
if (Time.time < _lastFireTime + _fireRate)
{
return;
}
Shoot();
_lastFireTime = Time.time;
}
private void Shoot()
{
// Use fire point if available, otherwise use player position
Vector3 spawnPosition = _firePoint != null ? _firePoint.position : transform.position;
GameObject bullet = Instantiate(_bulletPrefab, spawnPosition, transform.rotation);
Rigidbody bulletRigidbody = bullet.GetComponent<Rigidbody>();
if (bulletRigidbody != null)
{
bulletRigidbody.AddForce(transform.forward * _bulletForce);
}
else
{
Debug.LogWarning("Bullet prefab missing Rigidbody component");
}
}
public void TakeDamage(int amount)
{
if (amount < 0)
{
Debug.LogWarning("Attempted to take negative damage. Use Heal() instead.");
return;
}
_currentHealth -= amount;
_currentHealth = Mathf.Max(0, _currentHealth);
if (_currentHealth <= 0)
{
Die();
}
}
public void Heal(int amount)
{
if (amount < 0)
{
Debug.LogWarning("Attempted to heal negative amount. Use TakeDamage() instead.");
return;
}
if (!IsAlive)
{
return;
}
_currentHealth += amount;
_currentHealth = Mathf.Min(_currentHealth, _maxHealth);
}
private void Die()
{
Debug.Log("Player died");
// Trigger game over sequence instead of immediately destroying
// This allows for death animations, sound effects, etc.
OnPlayerDeath();
}
protected virtual void OnPlayerDeath()
{
// Virtual method allows subclasses to customize death behavior
// without modifying the core death logic
gameObject.SetActive(false);
}
}
The refactored version demonstrates several best practices:
- Proper naming conventions
- Organized class structure
- Encapsulation with private fields and appropriate properties
- Single-responsibility methods
- Input handling separated from physics
- Validation in damage and healing methods
- Better error handling and logging
- Serialized fields with headers for better organization in the Inspector
Conclusion
Following C# coding conventions and best practices will make your code more readable, maintainable, and less prone to bugs. As you continue to develop your programming skills, these practices will become second nature.
Remember that the goal of these conventions is to make your code better, not to restrict your creativity. Sometimes breaking a rule might make sense for your specific situation, but you should understand why the rule exists before deciding to break it.
Unity has its own set of best practices that build upon general C# conventions. The Unity documentation provides specific guidelines for optimizing performance in games, which is especially important for resource-constrained platforms like mobile devices and VR headsets.
Additionally, Unity's component-based architecture encourages composition over inheritance, which aligns well with modern C# best practices.
In the next section, we'll explore debugging techniques that will help you find and fix issues in your C# code efficiently.