11.3 - C# 9 Feature Recap for Unity
C# 9.0 was released in November 2020 alongside .NET 5, introducing several new features that enhance code clarity, reduce boilerplate, and improve performance. As Unity continues to update its .NET support, understanding these features becomes increasingly important for game developers.
In this section, we'll focus on the C# 9 features that are most relevant for Unity development, with practical examples of how they can be applied in game development scenarios.
Unity's .NET Support
Before diving into C# 9 features, it's important to understand Unity's .NET support:
- Unity 2020.2 and later support .NET Standard 2.1
- Unity 2021.2 and later support C# 9 features
- Unity 6.x (as mentioned in our course overview) supports most C# 9 features
Feature availability may vary depending on your Unity version. Always check the Unity documentation for your specific version to confirm which C# features are supported.
Key C# 9 Features for Unity Developers
1. Record Types
Records are a new reference type that provides built-in functionality for encapsulating data. They're particularly useful for representing immutable data structures.
Basic Syntax
// Basic record declaration
public record PlayerStats(int Health, int Mana, float MovementSpeed);
// Usage
PlayerStats warriorStats = new PlayerStats(100, 50, 5.0f);
// Creating a copy with modifications (non-destructive mutation)
PlayerStats enhancedWarrior = warriorStats with { Health = 150 };
Game Development Applications
Records are excellent for:
- Game Configuration Data:
public record GameDifficulty(
string Name,
float EnemyDamageMultiplier,
float PlayerHealthMultiplier,
int EnemySpawnRate
);
// Predefined difficulty levels
public static class GameDifficulties
{
public static readonly GameDifficulty Easy = new(
"Easy",
0.75f,
1.5f,
50
);
public static readonly GameDifficulty Normal = new(
"Normal",
1.0f,
1.0f,
100
);
public static readonly GameDifficulty Hard = new(
"Hard",
1.5f,
0.8f,
150
);
}
// Usage
GameDifficulty currentDifficulty = GameDifficulties.Normal;
// Custom difficulty based on Normal
GameDifficulty customDifficulty = currentDifficulty with {
Name = "Custom",
EnemySpawnRate = 120
};
- Event Payloads:
public record DamageEvent(
GameObject Target,
GameObject Source,
int Amount,
DamageType Type,
bool IsCritical
);
// Event system
public class GameEvents
{
public static event Action<DamageEvent> OnDamageDealt;
public static void RaiseDamageEvent(DamageEvent evt)
{
OnDamageDealt?.Invoke(evt);
}
}
// Usage
void DealDamage(GameObject target, int amount)
{
DamageEvent evt = new DamageEvent(
target,
gameObject,
amount,
DamageType.Physical,
false
);
GameEvents.RaiseDamageEvent(evt);
}
- Save Data:
public record PlayerSaveData(
string PlayerName,
int Level,
int Experience,
Vector3Data Position,
InventoryData Inventory,
DateTime LastSaved
);
public record Vector3Data(float X, float Y, float Z)
{
// Convert to Unity Vector3
public Vector3 ToVector3() => new Vector3(X, Y, Z);
// Create from Unity Vector3
public static Vector3Data FromVector3(Vector3 v) => new Vector3Data(v.x, v.y, v.z);
}
// Usage
PlayerSaveData saveData = new PlayerSaveData(
"Adventurer",
5,
1250,
Vector3Data.FromVector3(player.transform.position),
InventoryData.FromInventory(player.inventory),
DateTime.Now
);
// Save to JSON
string json = JsonUtility.ToJson(saveData);
File.WriteAllText("save.json", json);
2. Init-only Properties
Init-only properties can be set during object initialization but are read-only afterward, providing a balance between immutability and flexibility.
Basic Syntax
public class Enemy
{
// Init-only property - can only be set during initialization
public string EnemyType { get; init; }
// Regular read-write property
public int Health { get; set; }
// Regular read-only property
public bool IsElite { get; }
public Enemy(bool isElite)
{
IsElite = isElite;
}
}
// Usage
Enemy goblin = new Enemy(false)
{
EnemyType = "Goblin",
Health = 50
};
// Valid - regular property
goblin.Health = 40;
// Invalid - init-only property
// goblin.EnemyType = "Hobgoblin"; // This would cause a compilation error
Game Development Applications
Init-only properties are useful for:
- Configurable Prefab Scripts:
public class Weapon : MonoBehaviour
{
// These properties can be set in the inspector or at initialization
// but can't be changed during gameplay
public string WeaponName { get; init; } = "Unknown Weapon";
public WeaponType Type { get; init; } = WeaponType.Melee;
public int BaseDamage { get; init; } = 10;
// These can change during gameplay
public int CurrentAmmo { get; set; }
public bool IsEquipped { get; set; }
private void Awake()
{
// Last chance to set init-only properties programmatically
if (Type == WeaponType.Ranged && CurrentAmmo == 0)
{
CurrentAmmo = 30; // Default ammo for ranged weapons
}
}
}
- Level Configuration:
public class LevelConfig
{
// Core level properties that shouldn't change after initialization
public string LevelName { get; init; }
public int LevelNumber { get; init; }
public Difficulty LevelDifficulty { get; init; }
public string SceneName { get; init; }
// Dynamic properties that can change during gameplay
public int EnemiesRemaining { get; set; }
public bool BossDefeated { get; set; }
public float CompletionPercentage { get; set; }
}
// Usage
LevelConfig forestLevel = new LevelConfig
{
LevelName = "Enchanted Forest",
LevelNumber = 3,
LevelDifficulty = Difficulty.Medium,
SceneName = "Forest_Scene",
EnemiesRemaining = 25
};
// During gameplay
forestLevel.EnemiesRemaining--;
forestLevel.CompletionPercentage =
(1 - (float)forestLevel.EnemiesRemaining / 25) * 100;
3. Pattern Matching Enhancements
C# 9 introduces several enhancements to pattern matching, making it more powerful and expressive.
Basic Syntax
// Type patterns
object obj = GetSomeObject();
if (obj is string s)
{
Console.WriteLine($"String of length {s.Length}");
}
// Relational patterns
void CheckTemperature(int temperature)
{
string description = temperature switch
{
> 40 => "Very Hot",
> 30 => "Hot",
> 20 => "Warm",
> 10 => "Cool",
> 0 => "Cold",
_ => "Very Cold"
};
Console.WriteLine(description);
}
// Logical patterns (and, or, not)
bool IsValidPosition(Point p) => p is { X: > 0 and < 100, Y: > 0 and < 100 };
Game Development Applications
Enhanced pattern matching is useful for:
- Game State Management:
public enum GameState { MainMenu, Loading, Playing, Paused, GameOver, Victory }
public void HandleInput(GameState state, KeyCode keyPressed)
{
switch (state)
{
case GameState.MainMenu when keyPressed == KeyCode.Return:
StartGame();
break;
case GameState.Playing when keyPressed == KeyCode.Escape:
PauseGame();
break;
case GameState.Paused when keyPressed == KeyCode.Escape:
ResumeGame();
break;
case GameState.Playing when keyPressed == KeyCode.Space:
PlayerJump();
break;
case GameState.GameOver or GameState.Victory when keyPressed == KeyCode.R:
RestartGame();
break;
default:
// Handle other input combinations
break;
}
}
- Damage Calculation System:
public float CalculateDamage(Attack attack, Defense defense)
{
return (attack, defense) switch
{
(FireAttack f, WaterDefense w) => f.Power * 0.5f,
(FireAttack f, IceDefense i) => f.Power * 2.0f,
(WaterAttack w, FireDefense f) => w.Power * 2.0f,
(WaterAttack w, ElectricDefense e) => w.Power * 0.5f,
(ElectricAttack e, GroundDefense g) => e.Power * 0.0f,
(ElectricAttack e, WaterDefense w) => e.Power * 2.0f,
(PhysicalAttack { IsCritical: true }, _) => attack.Power * 2.0f,
(_, PhysicalDefense p) when p.BlockChance > Random.value => 0,
_ => attack.Power
};
}
- AI Decision Making:
public class EnemyAI : MonoBehaviour
{
public float health;
public float playerDistance;
public bool playerVisible;
public bool hasRangedWeapon;
private void Update()
{
AIState nextState = (health, playerDistance, playerVisible, hasRangedWeapon) switch
{
(< 20, _, _, _) => AIState.Flee,
(_, < 2, true, _) => AIState.MeleeAttack,
(_, < 10, true, true) => AIState.RangedAttack,
(_, < 20, true, _) => AIState.Chase,
(_, _, true, _) => AIState.Investigate,
_ => AIState.Patrol
};
SetState(nextState);
}
}
4. Target-typed New Expressions
This feature allows you to omit the type when creating an object when the type can be inferred from the context.
Basic Syntax
// Before C# 9
Dictionary<string, List<int>> scores = new Dictionary<string, List<int>>();
// With C# 9
Dictionary<string, List<int>> scores = new();
Game Development Applications
Target-typed new expressions are useful for:
- Cleaner MonoBehaviour References:
// Before C# 9
private Dictionary<EnemyType, List<Enemy>> enemiesByType = new Dictionary<EnemyType, List<Enemy>>();
private List<Vector3> spawnPoints = new List<Vector3>();
// With C# 9
private Dictionary<EnemyType, List<Enemy>> enemiesByType = new();
private List<Vector3> spawnPoints = new();
- Factory Methods:
public class WeaponFactory
{
public Weapon CreateWeapon(WeaponType type)
{
return type switch
{
WeaponType.Sword => new() { Damage = 10, Range = 1, AttackSpeed = 1.2f },
WeaponType.Bow => new() { Damage = 7, Range = 10, AttackSpeed = 0.8f },
WeaponType.Staff => new() { Damage = 15, Range = 8, AttackSpeed = 0.5f },
_ => new() // Default weapon
};
}
}
- Event Handling:
public class GameEventSystem : MonoBehaviour
{
private Dictionary<string, List<Action<EventData>>> eventHandlers = new();
public void RegisterHandler(string eventName, Action<EventData> handler)
{
if (!eventHandlers.ContainsKey(eventName))
{
eventHandlers[eventName] = new();
}
eventHandlers[eventName].Add(handler);
}
public void TriggerEvent(string eventName, EventData data)
{
if (eventHandlers.TryGetValue(eventName, out var handlers))
{
foreach (var handler in handlers)
{
handler(data);
}
}
}
}
5. Top-level Statements
Top-level statements allow you to write code directly at the namespace level, without needing to define a class or a Main method. While this feature is less relevant for Unity (which uses MonoBehaviours), it can be useful for tools and utilities.
Basic Syntax
// Before C# 9
using System;
namespace MyApp
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello World!");
}
}
}
// With C# 9
using System;
Console.WriteLine("Hello World!");
Game Development Applications
Top-level statements are useful for:
- Editor Tools and Utilities:
// AssetProcessor.cs - A simple Unity Editor tool
using UnityEngine;
using UnityEditor;
using System.IO;
// Top-level statements for a simple texture processor
string[] texturePaths = Directory.GetFiles("Assets/Textures", "*.png", SearchOption.AllDirectories);
int processedCount = 0;
foreach (string texturePath in texturePaths)
{
TextureImporter importer = AssetImporter.GetAtPath(texturePath) as TextureImporter;
if (importer != null)
{
importer.isReadable = true;
importer.textureCompression = TextureImporterCompression.Compressed;
importer.SaveAndReimport();
processedCount++;
}
}
Debug.Log($"Processed {processedCount} textures");
- Build Scripts:
// BuildScript.cs - A simple build automation script
using UnityEditor;
using System;
// Define build targets
string[] scenes = { "Assets/Scenes/MainMenu.unity", "Assets/Scenes/Level1.unity" };
string buildPath = "Builds/Windows/MyGame.exe";
// Build for Windows
BuildPipeline.BuildPlayer(scenes, buildPath, BuildTarget.StandaloneWindows, BuildOptions.None);
Console.WriteLine($"Build completed: {buildPath}");
6. Improved Target Typing
C# 9 improves type inference in several scenarios, making the code more concise.
Basic Syntax
// Target-typed conditional expressions
int? maybe = null;
int definitely = maybe ?? 0; // Type of right side inferred from left
// Target-typed new
List<int> numbers = new(); // Type inferred from variable declaration
Game Development Applications
Improved target typing is useful for:
- Configuration Objects:
[Serializable]
public class EnemyConfig
{
public string Name;
public int Health;
public float Speed;
public List<string> Abilities;
}
// Create configurations with target typing
public class EnemyDatabase : MonoBehaviour
{
public Dictionary<string, EnemyConfig> EnemyConfigs { get; private set; }
private void Awake()
{
EnemyConfigs = new()
{
["Goblin"] = new() { Name = "Goblin", Health = 50, Speed = 3.5f, Abilities = new() { "Stab", "Flee" } },
["Orc"] = new() { Name = "Orc", Health = 100, Speed = 2.8f, Abilities = new() { "Smash", "Roar" } },
["Troll"] = new() { Name = "Troll", Health = 200, Speed = 2.0f, Abilities = new() { "Crush", "Regenerate" } }
};
}
}
- UI Element Creation:
public class UIFactory
{
public Dictionary<string, RectTransform> CreateMainMenu()
{
// Return a dictionary of UI elements with target typing
return new()
{
["MainPanel"] = CreatePanel(new Vector2(0, 0), new Vector2(1920, 1080)),
["TitleText"] = CreateText("Game Title", 48, TextAnchor.MiddleCenter),
["StartButton"] = CreateButton("Start Game", () => StartGame()),
["OptionsButton"] = CreateButton("Options", () => OpenOptions()),
["QuitButton"] = CreateButton("Quit", () => QuitGame())
};
}
// Helper methods
private RectTransform CreatePanel(Vector2 position, Vector2 size) => new();
private RectTransform CreateText(string text, int fontSize, TextAnchor alignment) => new();
private RectTransform CreateButton(string text, Action onClick) => new();
// Game actions
private void StartGame() { }
private void OpenOptions() { }
private void QuitGame() { }
}
Practical Example: Quest System with C# 9 Features
Let's build a simple quest system that demonstrates several C# 9 features working together:
using System;
using System.Collections.Generic;
using UnityEngine;
// Record types for immutable quest data
public record QuestDefinition(
string Id,
string Title,
string Description,
QuestType Type,
int ExperienceReward,
List<ItemReward> ItemRewards,
List<QuestObjective> Objectives
);
public record ItemReward(string ItemId, int Quantity);
public record QuestObjective(
string Description,
int TargetAmount,
string TargetId = null
);
// Enum for quest types
public enum QuestType
{
Main,
Side,
Daily,
Hidden
}
// Quest state tracking with init-only properties
public class QuestState
{
// Quest definition is immutable after initialization
public QuestDefinition Definition { get; init; }
// Current state can change
public bool IsActive { get; set; }
public bool IsCompleted { get; set; }
public Dictionary<int, int> ObjectiveProgress { get; } = new();
// Track when the quest was accepted and completed
public DateTime AcceptedTime { get; init; }
public DateTime? CompletedTime { get; set; }
public QuestState(QuestDefinition definition)
{
Definition = definition;
AcceptedTime = DateTime.Now;
// Initialize objective progress
for (int i = 0; i < definition.Objectives.Count; i++)
{
ObjectiveProgress[i] = 0;
}
}
// Check if all objectives are complete
public bool AreAllObjectivesComplete()
{
for (int i = 0; i < Definition.Objectives.Count; i++)
{
if (ObjectiveProgress[i] < Definition.Objectives[i].TargetAmount)
{
return false;
}
}
return true;
}
}
// Quest manager using pattern matching and other C# 9 features
public class QuestManager : MonoBehaviour
{
// Dictionary of all quest definitions
private Dictionary<string, QuestDefinition> _questDefinitions = new();
// Player's active and completed quests
private List<QuestState> _activeQuests = new();
private List<QuestState> _completedQuests = new();
private void Awake()
{
InitializeQuestDefinitions();
}
private void InitializeQuestDefinitions()
{
// Create some sample quests using records
_questDefinitions["main_quest_1"] = new(
"main_quest_1",
"The Beginning of an Adventure",
"Speak with the village elder to learn about the ancient prophecy.",
QuestType.Main,
100,
new() { new("gold_coin", 50), new("healing_potion", 2) },
new() { new("Speak with Elder Thorne", 1, "npc_elder_thorne") }
);
_questDefinitions["side_quest_1"] = new(
"side_quest_1",
"Pest Control",
"The farmer's fields are overrun with rats. Clear them out.",
QuestType.Side,
50,
new() { new("farmer_hat", 1) },
new() { new("Defeat rats", 10, "enemy_rat") }
);
_questDefinitions["daily_quest_1"] = new(
"daily_quest_1",
"Daily Training",
"Complete your daily combat training.",
QuestType.Daily,
25,
new() { new("experience_scroll", 1) },
new() { new("Practice combat moves", 5, "training_dummy") }
);
}
// Accept a new quest
public bool AcceptQuest(string questId)
{
// Check if the quest exists
if (!_questDefinitions.TryGetValue(questId, out var questDefinition))
{
Debug.LogWarning($"Quest {questId} not found");
return false;
}
// Check if the quest is already active or completed
if (_activeQuests.Exists(q => q.Definition.Id == questId) ||
_completedQuests.Exists(q => q.Definition.Id == questId))
{
Debug.LogWarning($"Quest {questId} already accepted or completed");
return false;
}
// Create a new quest state and add it to active quests
QuestState newQuest = new(questDefinition)
{
IsActive = true
};
_activeQuests.Add(newQuest);
Debug.Log($"Accepted quest: {questDefinition.Title}");
return true;
}
// Update quest progress using pattern matching
public void UpdateQuestProgress(string targetId, int amount = 1)
{
foreach (var quest in _activeQuests)
{
if (!quest.IsActive) continue;
for (int i = 0; i < quest.Definition.Objectives.Count; i++)
{
QuestObjective objective = quest.Definition.Objectives[i];
// Use pattern matching to check if this objective matches the target
if (objective is { TargetId: string id } && id == targetId)
{
// Update progress
int currentProgress = quest.ObjectiveProgress[i];
int newProgress = Math.Min(currentProgress + amount, objective.TargetAmount);
quest.ObjectiveProgress[i] = newProgress;
Debug.Log($"Updated quest '{quest.Definition.Title}' objective '{objective.Description}': " +
$"{newProgress}/{objective.TargetAmount}");
// Check if the quest is now complete
if (quest.AreAllObjectivesComplete() && !quest.IsCompleted)
{
CompleteQuest(quest);
}
}
}
}
}
// Complete a quest
private void CompleteQuest(QuestState quest)
{
quest.IsCompleted = true;
quest.IsActive = false;
quest.CompletedTime = DateTime.Now;
_activeQuests.Remove(quest);
_completedQuests.Add(quest);
// Use pattern matching to determine special rewards based on quest type
string specialMessage = quest.Definition.Type switch
{
QuestType.Main => "You've advanced the main story!",
QuestType.Daily when DateTime.Now.Hour < 12 => "Early bird bonus: +10% XP!",
QuestType.Side when quest.Definition.ExperienceReward > 100 => "Major side quest completed!",
_ => "Quest completed!"
};
Debug.Log($"Completed quest: {quest.Definition.Title}. {specialMessage}");
// Grant rewards (simplified)
Debug.Log($"Rewarded {quest.Definition.ExperienceReward} XP");
foreach (var reward in quest.Definition.ItemRewards)
{
Debug.Log($"Rewarded {reward.Quantity}x {reward.ItemId}");
}
}
// Get quest details using pattern matching and target typing
public string GetQuestDetails(string questId)
{
// Try to find the quest in active or completed quests
QuestState quest = _activeQuests.Find(q => q.Definition.Id == questId);
quest ??= _completedQuests.Find(q => q.Definition.Id == questId);
if (quest == null)
{
// Check if it's a quest definition that hasn't been accepted yet
if (_questDefinitions.TryGetValue(questId, out var definition))
{
return $"Available Quest: {definition.Title}\n{definition.Description}";
}
return "Quest not found";
}
// Build quest details based on state
string status = (quest.IsActive, quest.IsCompleted) switch
{
(true, false) => "In Progress",
(false, true) => "Completed",
_ => "Unknown Status"
};
// Use target typing for string builder
var details = new System.Text.StringBuilder()
.AppendLine($"{quest.Definition.Title} - {status}")
.AppendLine(quest.Definition.Description)
.AppendLine();
// Add objectives
details.AppendLine("Objectives:");
for (int i = 0; i < quest.Definition.Objectives.Count; i++)
{
var objective = quest.Definition.Objectives[i];
int progress = quest.ObjectiveProgress[i];
details.AppendLine($"- {objective.Description}: {progress}/{objective.TargetAmount}");
}
// Add rewards
details.AppendLine("\nRewards:");
details.AppendLine($"- {quest.Definition.ExperienceReward} XP");
foreach (var reward in quest.Definition.ItemRewards)
{
details.AppendLine($"- {reward.Quantity}x {reward.ItemId}");
}
return details.ToString();
}
}
This quest system demonstrates several C# 9 features:
- Record types for immutable quest definitions and rewards
- Init-only properties for quest state initialization
- Pattern matching for quest progress and reward determination
- Target-typed new expressions for creating collections
- Improved target typing for string building and quest lookup
Conclusion
C# 9 introduces several features that can significantly improve your code quality and developer experience when working with Unity. Records provide a clean way to represent immutable data, init-only properties offer more control over object mutability, and enhanced pattern matching enables more expressive conditional logic.
As Unity continues to update its .NET support, these features will become increasingly important for game developers. By understanding and adopting these features now, you'll be better prepared for future Unity versions and able to write cleaner, more maintainable code.
While Unity's support for C# 9 features depends on the specific version you're using, most of these features are available in Unity 2021.2 and later. When using these features, consider the following Unity-specific points:
-
Records and Serialization: Unity's serialization system may not fully support all aspects of records. If you need to serialize record types, you might need to implement custom serialization.
-
Init-only Properties in the Inspector: Init-only properties won't appear in the Unity Inspector by default. You'll need to use the
[SerializeField]
attribute for fields you want to expose. -
Performance Considerations: Some C# 9 features might have performance implications in performance-critical code. Always profile your game to ensure these features don't introduce unexpected overhead.
-
Compatibility: If you're working on a team or distributing your code, ensure everyone is using a Unity version that supports these features.
In the next section, we'll explore how to bridge your C# knowledge to Unity-specific concepts and start applying what you've learned to actual game development.