Why Advanced Techniques Matter
Advanced C# isn't just about showing off your coding skills. These techniques solve real problems in game development: performance bottlenecks, memory management, code organization, and scalability. When your game needs to handle thousands of objects, complex AI systems, or smooth 60 FPS gameplay, advanced techniques become essential.
Understanding these concepts helps you write code that's faster, cleaner, and easier to maintain. You'll spend less time debugging and more time building features that matter.
Delegates and Events
Delegates and events are fundamental to creating flexible, decoupled game systems. They allow objects to communicate without direct references, making your code more modular and easier to extend.
Understanding Delegates
A delegate is a type that represents a method signature. Think of it as a variable that can hold a reference to a method. This enables you to pass methods as parameters or store them for later execution.
// Define a delegate type
public delegate void HealthChangedDelegate(int currentHealth, int maxHealth);
// Use the delegate
public class PlayerHealth : MonoBehaviour
{
public HealthChangedDelegate OnHealthChanged;
private int health = 100;
public void TakeDamage(int damage)
{
health -= damage;
OnHealthChanged?.Invoke(health, 100);
}
}
When to use delegates: When you need to pass methods as parameters, create callback systems, or implement observer patterns without the overhead of events.
Events for Decoupled Communication
Events are a special type of delegate that provides built-in encapsulation and safety. They're perfect for creating communication systems where multiple objects need to react to changes.
public class GameManager : MonoBehaviour
{
// Event declaration
public static event Action<int> OnScoreChanged;
public static event Action OnGameOver;
private int score = 0;
public void AddScore(int points)
{
score += points;
OnScoreChanged?.Invoke(score);
}
public void EndGame()
{
OnGameOver?.Invoke();
}
}
// Subscriber class
public class UIManager : MonoBehaviour
{
void OnEnable()
{
GameManager.OnScoreChanged += UpdateScoreDisplay;
GameManager.OnGameOver += ShowGameOverScreen;
}
void OnDisable()
{
GameManager.OnScoreChanged -= UpdateScoreDisplay;
GameManager.OnGameOver -= ShowGameOverScreen;
}
private void UpdateScoreDisplay(int newScore)
{
scoreText.text = $"Score: {newScore}";
}
private void ShowGameOverScreen()
{
gameOverPanel.SetActive(true);
}
}
Pro Tip: Always unsubscribe from events in OnDisable() to prevent memory leaks. The ?.Invoke() syntax safely calls the event only if subscribers exist.
Action and Func Delegates
C# provides generic delegate types that eliminate the need to define custom delegates for common scenarios.
// Action: for methods that return void
Action<int, string> onItemCollected = (id, name) =>
{
Debug.Log($"Collected {name} (ID: {id})");
};
// Func: for methods that return a value
Func<int, int, int> calculateDamage = (baseDamage, multiplier) =>
{
return baseDamage * multiplier;
};
// Usage
onItemCollected(42, "Health Potion");
int finalDamage = calculateDamage(10, 3);
Benefits: Less boilerplate code, cleaner syntax, and built-in support for lambda expressions.
LINQ for Game Data Management
Language Integrated Query (LINQ) provides powerful data manipulation capabilities. While it has performance overhead, LINQ is excellent for non-performance-critical code like UI updates, data analysis, and editor tools.
Filtering and Searching
public class InventoryManager : MonoBehaviour
{
private List<Item> allItems = new List<Item>();
// Find all items of a specific type
public List<Item> GetItemsByType(ItemType type)
{
return allItems.Where(item => item.Type == type).ToList();
}
// Find the highest value item
public Item GetMostValuableItem()
{
return allItems.OrderByDescending(item => item.Value).FirstOrDefault();
}
// Check if player has any rare items
public bool HasRareItems()
{
return allItems.Any(item => item.Rarity == ItemRarity.Rare);
}
}
Performance Considerations
LINQ creates temporary collections and has overhead. For hot paths (Update loops, frequently called methods), prefer traditional loops:
// Fast: Traditional loop
void Update()
{
for (int i = 0; i < enemies.Count; i++)
{
if (enemies[i].IsAlive)
{
enemies[i].UpdateAI();
}
}
}
// Slower: LINQ (avoid in Update)
void Update()
{
foreach (var enemy in enemies.Where(e => e.IsAlive))
{
enemy.UpdateAI();
}
}
Best Practice: Use LINQ for initialization, UI updates, and editor scripts. Avoid it in Update(), FixedUpdate(), or any method called every frame.
Async and Await
Asynchronous programming prevents your game from freezing during long operations like loading assets, network requests, or file I/O. Unity's coroutines are great, but async/await provides more flexibility and better error handling.
Basic Async Patterns
public class AssetLoader : MonoBehaviour
{
public async Task<Texture2D> LoadTextureAsync(string path)
{
using (UnityWebRequest request = UnityWebRequestTexture.GetTexture(path))
{
await request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
return DownloadHandlerTexture.GetContent(request);
}
else
{
Debug.LogError($"Failed to load texture: {request.error}");
return null;
}
}
}
// Usage
public async void LoadGameAssets()
{
Texture2D playerTexture = await LoadTextureAsync("player.png");
if (playerTexture != null)
{
playerRenderer.material.mainTexture = playerTexture;
}
}
}
Combining Async with Unity
Unity doesn't fully support async/await in all contexts, but you can bridge the gap:
public class GameLoader : MonoBehaviour
{
public async void Start()
{
await LoadGameAsync();
}
private async Task LoadGameAsync()
{
// Load multiple assets in parallel
var playerTask = LoadPlayer();
var levelTask = LoadLevel();
var audioTask = LoadAudio();
await Task.WhenAll(playerTask, levelTask, audioTask);
Debug.Log("All assets loaded!");
}
}
Important: Always use async void only for event handlers. For other methods, use async Task to enable proper error handling and awaitability.
Object Pooling
Object pooling is crucial for performance in games. Instead of constantly creating and destroying objects (which triggers garbage collection), you reuse objects from a pool.
Basic Pool Implementation
public class ObjectPool<T> where T : MonoBehaviour
{
private Queue<T> pool = new Queue<T>();
private T prefab;
private Transform parent;
public ObjectPool(T prefab, int initialSize, Transform parent = null)
{
this.prefab = prefab;
this.parent = parent;
for (int i = 0; i < initialSize; i++)
{
T obj = Object.Instantiate(prefab, parent);
obj.gameObject.SetActive(false);
pool.Enqueue(obj);
}
}
public T Get()
{
T obj;
if (pool.Count > 0)
{
obj = pool.Dequeue();
}
else
{
obj = Object.Instantiate(prefab, parent);
}
obj.gameObject.SetActive(true);
return obj;
}
public void Return(T obj)
{
obj.gameObject.SetActive(false);
pool.Enqueue(obj);
}
}
Usage Example
public class BulletManager : MonoBehaviour
{
public Bullet bulletPrefab;
private ObjectPool<Bullet> bulletPool;
void Start()
{
bulletPool = new ObjectPool<Bullet>(bulletPrefab, 50, transform);
}
public void FireBullet(Vector3 position, Vector3 direction)
{
Bullet bullet = bulletPool.Get();
bullet.transform.position = position;
bullet.Initialize(direction, () => bulletPool.Return(bullet));
}
}
Performance Impact: Object pooling can reduce garbage collection by 90% or more in bullet-hell games or particle-heavy effects.
Extension Methods
Extension methods let you add functionality to existing types without modifying their source code. They're perfect for creating utility functions that feel natural to use.
Creating Extensions
public static class VectorExtensions
{
// Calculate distance squared (faster than distance, no square root)
public static float DistanceSquared(this Vector3 a, Vector3 b)
{
float dx = a.x - b.x;
float dy = a.y - b.y;
float dz = a.z - b.z;
return dx * dx + dy * dy + dz * dz;
}
// Check if vector is approximately zero
public static bool IsNearZero(this Vector3 v, float threshold = 0.001f)
{
return v.sqrMagnitude < threshold * threshold;
}
}
// Usage
Vector3 playerPos = transform.position;
Vector3 enemyPos = enemy.transform.position;
if (playerPos.DistanceSquared(enemyPos) < 100f)
{
// Player is within 10 units
}
Transform Extensions
public static class TransformExtensions
{
public static void Reset(this Transform t)
{
t.position = Vector3.zero;
t.rotation = Quaternion.identity;
t.localScale = Vector3.one;
}
public static void LookAt2D(this Transform t, Vector3 target)
{
Vector3 direction = target - t.position;
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
t.rotation = Quaternion.AngleAxis(angle, Vector3.forward);
}
}
Properties and Indexers
Advanced property patterns can make your code more expressive and maintainable.
Auto-Properties with Initializers
public class PlayerStats
{
public int Health { get; set; } = 100;
public int MaxHealth { get; private set; } = 100;
public float Speed { get; set; } = 5f;
}
Computed Properties
public class HealthComponent : MonoBehaviour
{
[SerializeField] private int currentHealth = 100;
[SerializeField] private int maxHealth = 100;
public int Health
{
get => currentHealth;
set
{
currentHealth = Mathf.Clamp(value, 0, maxHealth);
OnHealthChanged?.Invoke(currentHealth, maxHealth);
}
}
public float HealthPercent => (float)currentHealth / maxHealth;
public bool IsAlive => currentHealth > 0;
public event Action<int, int> OnHealthChanged;
}
Indexers for Collection-Like Access
public class Inventory : MonoBehaviour
{
private Dictionary<string, Item> items = new Dictionary<string, Item>();
public Item this[string itemName]
{
get => items.TryGetValue(itemName, out Item item) ? item : null;
set => items[itemName] = value;
}
// Usage
// inventory["Sword"] = new Item();
// Item sword = inventory["Sword"];
}
Null-Conditional and Null-Coalescing Operators
These operators make null checking concise and readable.
Null-Conditional Operator (?.)
// Old way
if (player != null && player.health != null)
{
int hp = player.health.current;
}
// New way
int? hp = player?.health?.current;
Null-Coalescing Operator (??)
// Provide default value if null
string playerName = player?.name ?? "Unknown Player";
int score = currentScore ?? 0;
// Chain with null-coalescing assignment
items ??= new List<Item>();
Performance Optimization Techniques
Struct vs Class
Use structs for small, frequently created objects to reduce garbage collection:
// Use struct for small, value-like data
public struct Point
{
public int x;
public int y;
public Point(int x, int y)
{
this.x = x;
this.y = y;
}
}
// Use class for larger, reference-like data
public class PlayerData
{
public string name;
public int level;
public List<Item> inventory;
// ... many more fields
}
Rule of thumb: If it's smaller than 16 bytes and doesn't need inheritance, consider a struct.
String Interpolation vs Concatenation
// Slow: String concatenation
string message = "Player " + playerName + " has " + score + " points";
// Fast: String interpolation
string message = $"Player {playerName} has {score} points";
// Fastest: StringBuilder for loops
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
sb.Append($"Item {i}, ");
}
string result = sb.ToString();
Caching and Memoization
public class ExpensiveCalculation
{
private Dictionary<int, float> cache = new Dictionary<int, float>();
public float Calculate(int input)
{
if (cache.TryGetValue(input, out float result))
{
return result;
}
result = PerformExpensiveOperation(input);
cache[input] = result;
return result;
}
private float PerformExpensiveOperation(int input)
{
// Expensive calculation here
return input * 1.5f;
}
}
Common Mistakes to Avoid
Mistake 1: Overusing LINQ in Hot Paths
LINQ is convenient but has overhead. Don't use it in Update() or other frequently called methods.
Mistake 2: Forgetting to Unsubscribe from Events
Always unsubscribe in OnDisable() to prevent memory leaks and null reference exceptions.
Mistake 3: Creating Objects in Update Loops
Avoid new keyword in Update(). Use object pooling or cache objects outside the loop.
Mistake 4: Ignoring Garbage Collection
Monitor GC allocations with Unity Profiler. String operations, LINQ, and boxing create garbage.
Mistake 5: Premature Optimization
Write clear, maintainable code first. Optimize only when profiling shows actual performance problems.
Putting It All Together
Advanced C# techniques work best when combined thoughtfully. A well-designed game system might use:
- Events for decoupled communication between systems
- Object pooling for frequently spawned objects
- Async/await for asset loading
- Extension methods for utility functions
- Structs for performance-critical data
The key is understanding when each technique is appropriate. Not every problem needs the most advanced solution. Sometimes a simple array and a for loop is the best choice.
Start by mastering one technique at a time. Add events to your game's communication system. Implement object pooling for your bullets or particles. Create extension methods for common operations. As you become comfortable with each technique, you'll naturally see opportunities to apply others.
Next Steps
Ready to dive deeper? Practice these techniques in a small project. Create a simple game where you can experiment with events, object pooling, and async loading. The best way to learn advanced techniques is through hands-on experience.
For more game development tutorials, check out our complete guide to Unity game development or explore our programming resources for additional learning materials.
Want to see these techniques in action? Try our AI Game Builder to experiment with different programming patterns and see how they impact game performance.
Frequently Asked Questions
When should I use events instead of direct references?
Use events when you have one-to-many communication (one object notifying multiple listeners) or when you want to decouple systems. Direct references are fine for one-to-one communication or when tight coupling is acceptable.
Is LINQ too slow for games?
LINQ is fine for initialization, UI updates, and editor scripts. Avoid it in Update(), FixedUpdate(), or any method called every frame. For performance-critical code, use traditional loops.
Should I always use object pooling?
Object pooling is essential for objects created and destroyed frequently (bullets, particles, enemies). For objects created once or rarely (UI panels, level geometry), pooling adds unnecessary complexity.
Are structs always faster than classes?
Structs are faster for small, frequently created objects because they avoid heap allocation. However, they're passed by value, so large structs can be slower than classes. Use structs for data under 16 bytes that doesn't need inheritance.
How do I know if my code is optimized enough?
Use Unity's Profiler to measure actual performance. Don't optimize prematurely. Write clear code first, then optimize only the parts that profiling shows are bottlenecks.