5.14 - Records
Records are a relatively new feature in C# (introduced in C# 9.0) that provide a concise way to define immutable reference types with built-in value equality. They are particularly useful for representing data that should not change after creation.
Records are available in Unity projects that use .NET Standard 2.1 or later. As of Unity 2022.2, the default scripting runtime is .NET Standard 2.1, which supports records. If you're using an older version of Unity, you may need to upgrade or configure your project to use a newer .NET version.
What are Records?
A record is a reference type that provides built-in functionality for:
- Immutability (when using positional parameters)
- Value equality (two records are equal if all their properties are equal)
- Non-destructive mutation (creating a new record with some properties changed)
- Concise syntax for defining properties
- Automatic implementation of
ToString()
,Equals()
, andGetHashCode()
Records are ideal for:
- Data-centric types that should be immutable
- Types that are primarily used to store data
- Types where equality should be based on property values, not reference identity
- Scenarios where you need to create variations of an object with slight differences
Declaring Records
There are two main ways to declare a record in C#:
Positional Records
Positional records use a concise syntax similar to tuple types:
public record PlayerStats(string Name, int Health, int Mana, int Strength, int Dexterity, int Intelligence);
This single line of code creates a record with:
- Six read-only properties (
Name
,Health
,Mana
,Strength
,Dexterity
,Intelligence
) - A constructor that initializes these properties
- Deconstructor methods that allow you to extract the values
- Value equality based on the property values
- A
ToString()
method that displays the property values - A
Clone
method (via thewith
expression) for non-destructive mutation
Standard Records
You can also declare records using a more traditional class-like syntax:
public record PlayerStats
{
public string Name { get; init; }
public int Health { get; init; }
public int Mana { get; init; }
public int Strength { get; init; }
public int Dexterity { get; init; }
public int Intelligence { get; init; }
}
This syntax gives you more control over the properties and allows you to add methods, additional constructors, etc.
Using Records
Here's how you can use records in your code:
// Create a record instance
PlayerStats warrior = new PlayerStats("Warrior", 100, 20, 15, 10, 5);
// Access properties
Debug.Log($"Name: {warrior.Name}, Health: {warrior.Health}");
// Records are immutable, so you can't change properties directly
// warrior.Health = 90; // This would cause a compilation error
// Create a new record with some properties changed using the "with" expression
PlayerStats injuredWarrior = warrior with { Health = 70 };
// The original record is unchanged
Debug.Log($"Original warrior health: {warrior.Health}"); // Output: 100
Debug.Log($"Injured warrior health: {injuredWarrior.Health}"); // Output: 70
// Value equality
PlayerStats anotherWarrior = new PlayerStats("Warrior", 100, 20, 15, 10, 5);
bool areEqual = warrior.Equals(anotherWarrior); // true
Debug.Log($"Are the warriors equal? {areEqual}"); // Output: true
// Deconstruction
var (name, health, mana, strength, dexterity, intelligence) = warrior;
Debug.Log($"Deconstructed: {name}, {health}, {mana}, {strength}, {dexterity}, {intelligence}");
Records vs. Classes vs. Structs
Understanding the differences between records, classes, and structs is crucial for using them effectively:
Feature | Record | Class | Struct |
---|---|---|---|
Type | Reference type | Reference type | Value type |
Mutability | Immutable by default | Mutable by default | Mutable by default |
Equality | Value equality | Reference equality | Value equality |
Inheritance | Can inherit and be inherited from | Can inherit and be inherited from | Cannot inherit or be inherited from |
Syntax | Concise | Verbose | Verbose |
Clone/Copy | Built-in (with expression) | Manual implementation | Manual implementation |
Performance | Similar to classes | Baseline | Can be more efficient for small types |
When to use records:
- For immutable data models
- When you need value equality
- When you want concise syntax for data-centric types
- When you need non-destructive mutation
When to use classes:
- For mutable objects
- When you need reference equality
- When you need complex behavior
- When you need inheritance
When to use structs:
- For small, simple value types
- When you need value semantics
- When you want to avoid heap allocations
- When the size is less than 16 bytes (general guideline)
Records in Unity
While Unity's core API doesn't use records (as they are a relatively new feature), you can use them in your own code to represent game data that shouldn't change after creation.
Example: Game Configuration
// Game settings record
public record GameSettings(
float MasterVolume,
float MusicVolume,
float SfxVolume,
int ResolutionWidth,
int ResolutionHeight,
bool Fullscreen,
int QualityLevel,
float MouseSensitivity
);
// Settings manager
public class SettingsManager : MonoBehaviour
{
private GameSettings currentSettings;
private void Awake()
{
LoadSettings();
}
public void LoadSettings()
{
// Load settings from PlayerPrefs
float masterVolume = PlayerPrefs.GetFloat("MasterVolume", 1f);
float musicVolume = PlayerPrefs.GetFloat("MusicVolume", 0.8f);
float sfxVolume = PlayerPrefs.GetFloat("SFXVolume", 1f);
int resolutionWidth = PlayerPrefs.GetInt("ResolutionWidth", 1920);
int resolutionHeight = PlayerPrefs.GetInt("ResolutionHeight", 1080);
bool fullscreen = PlayerPrefs.GetInt("Fullscreen", 1) == 1;
int qualityLevel = PlayerPrefs.GetInt("QualityLevel", 2);
float mouseSensitivity = PlayerPrefs.GetFloat("MouseSensitivity", 1f);
currentSettings = new GameSettings(
Mathf.Clamp01(masterVolume),
Mathf.Clamp01(musicVolume),
Mathf.Clamp01(sfxVolume),
Mathf.Max(640, resolutionWidth),
Mathf.Max(480, resolutionHeight),
fullscreen,
Mathf.Clamp(qualityLevel, 0, QualitySettings.names.Length - 1),
Mathf.Clamp(mouseSensitivity, 0.1f, 10f)
);
ApplySettings();
}
public void SaveSettings()
{
// Save settings to PlayerPrefs
PlayerPrefs.SetFloat("MasterVolume", currentSettings.MasterVolume);
PlayerPrefs.SetFloat("MusicVolume", currentSettings.MusicVolume);
PlayerPrefs.SetFloat("SFXVolume", currentSettings.SfxVolume);
PlayerPrefs.SetInt("ResolutionWidth", currentSettings.ResolutionWidth);
PlayerPrefs.SetInt("ResolutionHeight", currentSettings.ResolutionHeight);
PlayerPrefs.SetInt("Fullscreen", currentSettings.Fullscreen ? 1 : 0);
PlayerPrefs.SetInt("QualityLevel", currentSettings.QualityLevel);
PlayerPrefs.SetFloat("MouseSensitivity", currentSettings.MouseSensitivity);
PlayerPrefs.Save();
}
public void ApplySettings()
{
// Apply audio settings
AudioListener.volume = currentSettings.MasterVolume;
// Find and set volume on audio sources
AudioSource[] audioSources = FindObjectsOfType<AudioSource>();
foreach (AudioSource source in audioSources)
{
if (source.CompareTag("Music"))
{
source.volume = currentSettings.MusicVolume;
}
else
{
source.volume = currentSettings.SfxVolume;
}
}
// Apply graphics settings
Screen.SetResolution(currentSettings.ResolutionWidth, currentSettings.ResolutionHeight, currentSettings.Fullscreen);
QualitySettings.SetQualityLevel(currentSettings.QualityLevel);
// Apply mouse sensitivity
// This would typically be used in your input handling code
}
public void ResetToDefaults()
{
currentSettings = new GameSettings(1f, 0.8f, 1f, 1920, 1080, true, 2, 1f);
SaveSettings();
ApplySettings();
}
public void SetMasterVolume(float volume)
{
currentSettings = currentSettings with { MasterVolume = Mathf.Clamp01(volume) };
}
public void SetMusicVolume(float volume)
{
currentSettings = currentSettings with { MusicVolume = Mathf.Clamp01(volume) };
}
public void SetSFXVolume(float volume)
{
currentSettings = currentSettings with { SfxVolume = Mathf.Clamp01(volume) };
}
public void SetResolution(int width, int height)
{
currentSettings = currentSettings with {
ResolutionWidth = Mathf.Max(640, width),
ResolutionHeight = Mathf.Max(480, height)
};
}
public void SetFullscreen(bool fullscreen)
{
currentSettings = currentSettings with { Fullscreen = fullscreen };
}
public void SetQualityLevel(int level)
{
currentSettings = currentSettings with {
QualityLevel = Mathf.Clamp(level, 0, QualitySettings.names.Length - 1)
};
}
public void SetMouseSensitivity(float sensitivity)
{
currentSettings = currentSettings with {
MouseSensitivity = Mathf.Clamp(sensitivity, 0.1f, 10f)
};
}
public GameSettings GetCurrentSettings()
{
return currentSettings;
}
}
In this example, we use a GameSettings
record to represent the game's configuration settings. The record is immutable, so we use the with
expression to create new instances with modified properties.
Example: Quest System
// Quest status enum
public enum QuestStatus
{
NotStarted,
InProgress,
Completed,
Failed
}
// Quest record
public record Quest(
string Id,
string Title,
string Description,
int ExperienceReward,
int GoldReward,
string[] ItemRewards,
QuestObjective[] Objectives,
QuestStatus Status = QuestStatus.NotStarted
);
// Quest objective record
public record QuestObjective(
string Description,
int CurrentAmount,
int RequiredAmount,
bool IsCompleted = false
);
// Quest manager
public class QuestManager : MonoBehaviour
{
private Dictionary<string, Quest> quests = new Dictionary<string, Quest>();
private void Start()
{
// Initialize with some example quests
InitializeQuests();
}
private void InitializeQuests()
{
// Create a simple quest
Quest ratHuntQuest = new Quest(
"Q001",
"Rat Hunt",
"The town is overrun with rats. Clear them out to help the townspeople.",
100,
50,
new string[] { "Rat Tail", "Minor Health Potion" },
new QuestObjective[]
{
new QuestObjective("Kill rats", 0, 10),
new QuestObjective("Return to the town mayor", 0, 1)
}
);
// Add the quest to the dictionary
quests.Add(ratHuntQuest.Id, ratHuntQuest);
// Create another quest
Quest deliveryQuest = new Quest(
"Q002",
"Special Delivery",
"Deliver a package to the neighboring village.",
150,
75,
new string[] { "Traveler's Boots" },
new QuestObjective[]
{
new QuestObjective("Collect the package from the merchant", 0, 1),
new QuestObjective("Deliver the package to the village elder", 0, 1)
}
);
// Add the quest to the dictionary
quests.Add(deliveryQuest.Id, deliveryQuest);
}
public Quest GetQuest(string questId)
{
if (quests.TryGetValue(questId, out Quest quest))
{
return quest;
}
Debug.LogWarning($"Quest with ID {questId} not found!");
return null;
}
public void StartQuest(string questId)
{
if (quests.TryGetValue(questId, out Quest quest))
{
if (quest.Status == QuestStatus.NotStarted)
{
// Create a new quest with updated status
Quest updatedQuest = quest with { Status = QuestStatus.InProgress };
// Update the quest in the dictionary
quests[questId] = updatedQuest;
Debug.Log($"Started quest: {updatedQuest.Title}");
}
else
{
Debug.LogWarning($"Cannot start quest {quest.Title} because it's already in progress or completed.");
}
}
else
{
Debug.LogWarning($"Quest with ID {questId} not found!");
}
}
public void UpdateObjectiveProgress(string questId, int objectiveIndex, int amount)
{
if (quests.TryGetValue(questId, out Quest quest))
{
if (quest.Status != QuestStatus.InProgress)
{
Debug.LogWarning($"Cannot update objective for quest {quest.Title} because it's not in progress.");
return;
}
if (objectiveIndex < 0 || objectiveIndex >= quest.Objectives.Length)
{
Debug.LogWarning($"Invalid objective index {objectiveIndex} for quest {quest.Title}.");
return;
}
QuestObjective objective = quest.Objectives[objectiveIndex];
if (objective.IsCompleted)
{
Debug.Log($"Objective '{objective.Description}' is already completed.");
return;
}
// Calculate the new amount
int newAmount = Mathf.Min(objective.CurrentAmount + amount, objective.RequiredAmount);
bool isCompleted = newAmount >= objective.RequiredAmount;
// Create a new objective with updated progress
QuestObjective updatedObjective = objective with {
CurrentAmount = newAmount,
IsCompleted = isCompleted
};
// Create a new array of objectives with the updated objective
QuestObjective[] updatedObjectives = new QuestObjective[quest.Objectives.Length];
Array.Copy(quest.Objectives, updatedObjectives, quest.Objectives.Length);
updatedObjectives[objectiveIndex] = updatedObjective;
// Check if all objectives are completed
bool allObjectivesCompleted = true;
foreach (QuestObjective obj in updatedObjectives)
{
if (!obj.IsCompleted)
{
allObjectivesCompleted = false;
break;
}
}
// Create a new quest with updated objectives and possibly status
Quest updatedQuest = quest with {
Objectives = updatedObjectives,
Status = allObjectivesCompleted ? QuestStatus.Completed : QuestStatus.InProgress
};
// Update the quest in the dictionary
quests[questId] = updatedQuest;
Debug.Log($"Updated objective '{objective.Description}' progress: {newAmount}/{objective.RequiredAmount}");
if (isCompleted)
{
Debug.Log($"Objective completed: {objective.Description}");
}
if (allObjectivesCompleted)
{
Debug.Log($"Quest completed: {quest.Title}");
GiveQuestRewards(updatedQuest);
}
}
else
{
Debug.LogWarning($"Quest with ID {questId} not found!");
}
}
public void FailQuest(string questId)
{
if (quests.TryGetValue(questId, out Quest quest))
{
if (quest.Status == QuestStatus.InProgress)
{
// Create a new quest with updated status
Quest updatedQuest = quest with { Status = QuestStatus.Failed };
// Update the quest in the dictionary
quests[questId] = updatedQuest;
Debug.Log($"Failed quest: {updatedQuest.Title}");
}
else
{
Debug.LogWarning($"Cannot fail quest {quest.Title} because it's not in progress.");
}
}
else
{
Debug.LogWarning($"Quest with ID {questId} not found!");
}
}
private void GiveQuestRewards(Quest quest)
{
Debug.Log($"Giving rewards for quest: {quest.Title}");
Debug.Log($"Experience: {quest.ExperienceReward}");
Debug.Log($"Gold: {quest.GoldReward}");
foreach (string item in quest.ItemRewards)
{
Debug.Log($"Item: {item}");
}
// In a real game, you would add the rewards to the player's inventory, etc.
}
public List<Quest> GetAllQuests()
{
return new List<Quest>(quests.Values);
}
public List<Quest> GetQuestsByStatus(QuestStatus status)
{
return quests.Values.Where(q => q.Status == status).ToList();
}
}
In this example, we use records to represent quests and quest objectives. The records are immutable, so we use the with
expression to create new instances with updated properties.
Example: Character Stats
// Character stats record
public record CharacterStats(
string Name,
int Level,
int Health,
int MaxHealth,
int Mana,
int MaxMana,
int Strength,
int Dexterity,
int Intelligence,
int Defense,
int MagicResistance
)
{
// Derived properties
public int PhysicalDamage => Strength * 2 + Level;
public int MagicalDamage => Intelligence * 2 + Level;
public float CriticalChance => Dexterity * 0.01f;
public float DodgeChance => Dexterity * 0.005f;
// Methods
public bool IsDead => Health <= 0;
public CharacterStats WithDamage(int damage)
{
int newHealth = Mathf.Max(0, Health - damage);
return this with { Health = newHealth };
}
public CharacterStats WithHealing(int healing)
{
int newHealth = Mathf.Min(MaxHealth, Health + healing);
return this with { Health = newHealth };
}
public CharacterStats WithManaUsed(int manaUsed)
{
int newMana = Mathf.Max(0, Mana - manaUsed);
return this with { Mana = newMana };
}
public CharacterStats WithManaRestored(int manaRestored)
{
int newMana = Mathf.Min(MaxMana, Mana + manaRestored);
return this with { Mana = newMana };
}
public CharacterStats WithLevelUp()
{
return this with {
Level = Level + 1,
MaxHealth = MaxHealth + 10,
Health = MaxHealth + 10,
MaxMana = MaxMana + 5,
Mana = MaxMana + 5,
Strength = Strength + 1,
Dexterity = Dexterity + 1,
Intelligence = Intelligence + 1,
Defense = Defense + 1,
MagicResistance = MagicResistance + 1
};
}
}
// Character controller
public class CharacterController : MonoBehaviour
{
[SerializeField] private string characterName = "Hero";
[SerializeField] private int startingLevel = 1;
[SerializeField] private int startingHealth = 100;
[SerializeField] private int startingMana = 50;
[SerializeField] private int startingStrength = 10;
[SerializeField] private int startingDexterity = 8;
[SerializeField] private int startingIntelligence = 5;
[SerializeField] private int startingDefense = 5;
[SerializeField] private int startingMagicResistance = 3;
private CharacterStats stats;
private void Awake()
{
// Initialize character stats
stats = new CharacterStats(
characterName,
startingLevel,
startingHealth,
startingHealth,
startingMana,
startingMana,
startingStrength,
startingDexterity,
startingIntelligence,
startingDefense,
startingMagicResistance
);
Debug.Log($"Character initialized: {stats}");
}
public void TakeDamage(int damage)
{
Debug.Log($"{stats.Name} takes {damage} damage!");
// Create a new stats record with reduced health
stats = stats.WithDamage(damage);
if (stats.IsDead)
{
Die();
}
}
public void Heal(int amount)
{
Debug.Log($"{stats.Name} heals for {amount} health!");
// Create a new stats record with increased health
stats = stats.WithHealing(amount);
}
public void UseMana(int amount)
{
if (stats.Mana >= amount)
{
Debug.Log($"{stats.Name} uses {amount} mana!");
// Create a new stats record with reduced mana
stats = stats.WithManaUsed(amount);
}
else
{
Debug.Log($"{stats.Name} doesn't have enough mana!");
}
}
public void RestoreMana(int amount)
{
Debug.Log($"{stats.Name} restores {amount} mana!");
// Create a new stats record with increased mana
stats = stats.WithManaRestored(amount);
}
public void LevelUp()
{
Debug.Log($"{stats.Name} levels up!");
// Create a new stats record with increased level and stats
stats = stats.WithLevelUp();
Debug.Log($"New stats: {stats}");
}
private void Die()
{
Debug.Log($"{stats.Name} has died!");
// Handle character death
// ...
}
public CharacterStats GetStats()
{
return stats;
}
}
In this example, we use a CharacterStats
record to represent a character's statistics. The record includes derived properties and methods that return new instances with modified properties.
Advanced Record Features
Inheritance
Records can inherit from other records:
public record Person(string Name, int Age);
public record Student(string Name, int Age, string StudentId) : Person(Name, Age);
When a record inherits from another record:
- The derived record includes all the properties of the base record
- Value equality considers all properties from both records
- The
ToString()
method includes all properties from both records - The
with
expression can update properties from both records
Record Structs
Starting with C# 10, you can also create record structs, which combine the features of records and structs:
public record struct Point(float X, float Y, float Z);
Record structs:
- Are value types (like structs)
- Have value equality (like records)
- Support the
with
expression (like records) - Have automatically generated
ToString()
,Equals()
, andGetHashCode()
methods (like records)
Init-only Properties
Records often use init-only properties, which can only be set during object initialization:
public record Person
{
public string Name { get; init; }
public int Age { get; init; }
}
// Usage
var person = new Person { Name = "John", Age = 30 };
// person.Age = 31; // This would cause a compilation error
Init-only properties help enforce immutability while still allowing object initialization.
Best Practices for Records
-
Use Records for Immutable Data: Records are designed for immutable data. If you need mutable data, consider using a class instead.
-
Keep Records Focused: Records should represent a single concept or entity. Avoid creating records with too many properties.
-
Use Positional Syntax for Simple Records: For simple records with few properties, use the positional syntax for conciseness.
-
Use Standard Syntax for Complex Records: For records with many properties or custom behavior, use the standard syntax for clarity.
-
Consider Record Structs for Small Value Types: If you need a small, immutable value type, consider using a record struct.
-
Use Inheritance Judiciously: While records support inheritance, use it judiciously to avoid creating complex hierarchies.
-
Document Record Behavior: Clearly document the behavior of your records, especially if they have custom methods or derived properties.
-
Use the
with
Expression for Non-destructive Mutation: When you need to create a modified version of a record, use thewith
expression. -
Consider Performance Implications: Records are reference types (unless you use record structs), so they have the same performance characteristics as classes.
-
Be Aware of Serialization Limitations: Some serialization libraries may not fully support records. Test thoroughly if you need to serialize records.
Conclusion
Records are a powerful feature in C# that provide a concise way to define immutable reference types with built-in value equality. They are particularly useful for representing data that should not change after creation.
Key points to remember about records:
- Records are reference types (unless you use record structs)
- Records are immutable by default
- Records have value equality
- Records support non-destructive mutation via the
with
expression - Records automatically implement
ToString()
,Equals()
, andGetHashCode()
By using records effectively, you can create more concise, maintainable, and robust code for your Unity games.
Practice Exercise
Exercise: Design a simple inventory system for a game with the following requirements:
-
Create an
Item
record with:- Properties for name, description, value, weight, and rarity
- Methods for calculating sell price based on value and rarity
-
Create derived records for different item types (e.g.,
WeaponItem
,ArmorItem
,ConsumableItem
) that:- Inherit from the base
Item
record - Add type-specific properties (e.g., damage for weapons, defense for armor)
- Override methods as needed
- Inherit from the base
-
Create an
Inventory
class that:- Stores a collection of items
- Provides methods for adding, removing, and finding items
- Enforces a weight limit
Think about how records help you represent and manipulate inventory data in your game.