Skip to main content

5.13 - Structs

Structs are a fundamental feature in C# that allow you to create lightweight value types. They are particularly useful in game development for representing simple data structures that don't require the overhead of classes.

What are Structs?

A struct is a value type that can contain data members (fields) and function members (methods, properties, etc.), similar to a class. However, unlike classes (which are reference types), structs are value types, which means they are stored on the stack rather than the heap and are passed by value rather than by reference.

Structs are ideal for small, simple data structures that:

  • Have a small memory footprint
  • Are immutable or rarely change
  • Don't need to participate in inheritance
  • Are frequently created and destroyed

Declaring Structs

To declare a struct in C#, you use the struct keyword:

public struct Point
{
public float x;
public float y;

public Point(float x, float y)
{
this.x = x;
this.y = y;
}

public float Magnitude()
{
return Mathf.Sqrt(x * x + y * y);
}

public void Normalize()
{
float mag = Magnitude();
if (mag > 0)
{
x /= mag;
y /= mag;
}
}

public override string ToString()
{
return $"({x}, {y})";
}
}

Key points about structs:

  • Structs are value types, not reference types
  • Structs cannot inherit from other structs or classes
  • Structs cannot be inherited from
  • Structs can implement interfaces
  • Structs can have constructors, methods, properties, fields, etc.
  • Structs cannot have a parameterless constructor (before C# 10)
  • Structs cannot initialize instance fields at their declaration (before C# 11)
  • All struct constructors must initialize all fields

Structs vs. Classes

Understanding the differences between structs and classes is crucial for using them effectively:

FeatureStructClass
TypeValue typeReference type
StorageStack (usually)Heap
PassingBy value (copy)By reference
Default valueZero-initializednull
InheritanceCannot inherit or be inherited fromCan inherit and be inherited from
ConstructorsCannot have parameterless constructor (before C# 10)Can have any constructor
DestructorsCannot have destructorsCan have destructors
Field initializationCannot initialize instance fields at declaration (before C# 11)Can initialize instance fields at declaration

When to use structs:

  • For small data structures that primarily store data
  • When you want value semantics (copying rather than referencing)
  • When the data structure is immutable or rarely changes
  • When you need to avoid heap allocations for performance reasons
  • When the size of the struct is less than 16 bytes (general guideline)

When to use classes:

  • For larger data structures
  • When you need reference semantics
  • When the object needs to change frequently
  • When you need inheritance
  • When the size of the object is larger than 16 bytes (general guideline)

Value Semantics

One of the key differences between structs and classes is that structs have value semantics, while classes have reference semantics. This means that when you assign a struct to a variable or pass it to a method, a copy of the struct is created:

Point p1 = new Point(3, 4);
Point p2 = p1; // p2 is a copy of p1

p2.x = 10; // This does not affect p1.x
Debug.Log($"p1: {p1}"); // Output: p1: (3, 4)
Debug.Log($"p2: {p2}"); // Output: p2: (10, 4)

In contrast, when you assign a class instance to a variable or pass it to a method, only a reference to the object is copied:

class PointClass
{
public float x;
public float y;

public PointClass(float x, float y)
{
this.x = x;
this.y = y;
}

public override string ToString()
{
return $"({x}, {y})";
}
}

PointClass pc1 = new PointClass(3, 4);
PointClass pc2 = pc1; // pc2 references the same object as pc1

pc2.x = 10; // This affects pc1.x as well
Debug.Log($"pc1: {pc1}"); // Output: pc1: (10, 4)
Debug.Log($"pc2: {pc2}"); // Output: pc2: (10, 4)

This difference in behavior is important to understand when deciding whether to use a struct or a class.

Performance Considerations

Structs can offer performance benefits in certain scenarios:

  • Structs are allocated on the stack, which is faster than heap allocation
  • Structs don't require garbage collection
  • Structs can reduce memory fragmentation
  • Structs can improve cache locality

However, structs also have performance drawbacks:

  • Copying large structs can be expensive
  • Passing large structs to methods can be expensive
  • Boxing structs (converting them to reference types) can be expensive

As a general guideline, structs are most efficient when they are small (less than 16 bytes) and immutable.

Structs in Unity

Unity uses structs extensively in its API. Here are some examples:

  • Vector2, Vector3, Vector4: Represent 2D, 3D, and 4D vectors
  • Quaternion: Represents rotations
  • Color: Represents RGBA colors
  • Rect: Represents a 2D rectangle
  • Bounds: Represents a 3D bounding box
  • RaycastHit: Contains information about a raycast hit

These structs are used frequently in Unity development, and understanding how they work is essential for writing efficient code.

Example: Using Unity's Built-in Structs

public class PlayerController : MonoBehaviour
{
[SerializeField] private float moveSpeed = 5f;
[SerializeField] private float jumpForce = 5f;

private Rigidbody rb;

private void Awake()
{
rb = GetComponent<Rigidbody>();
}

private void Update()
{
// Get input
float horizontal = Input.GetAxis("Horizontal");
float vertical = Input.GetAxis("Vertical");

// Create a movement vector using Vector3 struct
Vector3 movement = new Vector3(horizontal, 0f, vertical) * moveSpeed * Time.deltaTime;

// Apply movement
transform.Translate(movement);

// Jump
if (Input.GetKeyDown(KeyCode.Space))
{
rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
}

// Check if player is out of bounds
Bounds levelBounds = new Bounds(Vector3.zero, new Vector3(100f, 10f, 100f));
if (!levelBounds.Contains(transform.position))
{
Debug.Log("Player out of bounds!");
transform.position = Vector3.zero; // Reset position
}

// Cast a ray forward
Ray ray = new Ray(transform.position, transform.forward);
RaycastHit hit;

if (Physics.Raycast(ray, out hit, 10f))
{
Debug.Log($"Hit {hit.collider.name} at distance {hit.distance}");

// Draw a debug line to the hit point
Debug.DrawLine(ray.origin, hit.point, Color.red);

// Draw a debug sphere at the hit point
Debug.DrawRay(hit.point, hit.normal, Color.blue);
}
}

private void OnDrawGizmos()
{
// Draw a wire sphere around the player
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, 1f);

// Draw a wire cube representing the player's bounds
Gizmos.color = Color.green;
Bounds playerBounds = new Bounds(transform.position, new Vector3(1f, 2f, 1f));
Gizmos.DrawWireCube(playerBounds.center, playerBounds.size);
}
}

In this example, we use several of Unity's built-in structs:

  • Vector3 for representing positions and directions
  • Bounds for representing bounding boxes
  • Ray for representing rays
  • RaycastHit for storing information about raycast hits
  • Color for representing colors

These structs are passed by value, but because they are small and optimized, this doesn't cause performance issues.

Creating Custom Structs

You can create your own structs to represent game-specific data:

// Struct to represent a game item
public struct Item
{
public string name;
public int id;
public float weight;
public ItemType type;

public Item(string name, int id, float weight, ItemType type)
{
this.name = name;
this.id = id;
this.weight = weight;
this.type = type;
}

public override string ToString()
{
return $"{name} (ID: {id}, Weight: {weight}, Type: {type})";
}
}

// Enum to represent item types
public enum ItemType
{
Weapon,
Armor,
Consumable,
Quest,
Miscellaneous
}

// Struct to represent player stats
public struct PlayerStats
{
public int health;
public int mana;
public int strength;
public int dexterity;
public int intelligence;

public PlayerStats(int health, int mana, int strength, int dexterity, int intelligence)
{
this.health = health;
this.mana = mana;
this.strength = strength;
this.dexterity = dexterity;
this.intelligence = intelligence;
}

public int GetAttackPower()
{
return strength * 2 + dexterity;
}

public int GetDefensePower()
{
return strength + dexterity * 2;
}

public int GetMagicPower()
{
return intelligence * 3;
}

public override string ToString()
{
return $"HP: {health}, MP: {mana}, STR: {strength}, DEX: {dexterity}, INT: {intelligence}";
}
}

// Struct to represent a game level
public struct LevelData
{
public string name;
public int levelNumber;
public Difficulty difficulty;
public Vector3 playerStartPosition;
public string[] requiredEnemies;
public string[] requiredItems;

public LevelData(string name, int levelNumber, Difficulty difficulty, Vector3 playerStartPosition)
{
this.name = name;
this.levelNumber = levelNumber;
this.difficulty = difficulty;
this.playerStartPosition = playerStartPosition;
this.requiredEnemies = new string[0];
this.requiredItems = new string[0];
}

public override string ToString()
{
return $"Level {levelNumber}: {name} ({difficulty})";
}
}

// Enum to represent difficulty levels
public enum Difficulty
{
Easy,
Normal,
Hard,
Expert
}

// Example usage
public class GameManager : MonoBehaviour
{
private List<Item> inventory = new List<Item>();
private PlayerStats playerStats;
private LevelData currentLevel;

private void Start()
{
// Initialize player stats
playerStats = new PlayerStats(100, 50, 10, 8, 5);
Debug.Log($"Player stats: {playerStats}");
Debug.Log($"Attack power: {playerStats.GetAttackPower()}");
Debug.Log($"Defense power: {playerStats.GetDefensePower()}");
Debug.Log($"Magic power: {playerStats.GetMagicPower()}");

// Initialize inventory
AddItemToInventory(new Item("Iron Sword", 1001, 5.0f, ItemType.Weapon));
AddItemToInventory(new Item("Leather Armor", 2001, 8.0f, ItemType.Armor));
AddItemToInventory(new Item("Health Potion", 3001, 0.5f, ItemType.Consumable));

// Initialize current level
currentLevel = new LevelData("Forest of Doom", 1, Difficulty.Normal, new Vector3(0, 0, 0));
Debug.Log($"Current level: {currentLevel}");
}

private void AddItemToInventory(Item item)
{
inventory.Add(item);
Debug.Log($"Added item to inventory: {item}");
}

private void RemoveItemFromInventory(int id)
{
for (int i = 0; i < inventory.Count; i++)
{
if (inventory[i].id == id)
{
Item item = inventory[i];
inventory.RemoveAt(i);
Debug.Log($"Removed item from inventory: {item}");
return;
}
}

Debug.Log($"Item with ID {id} not found in inventory");
}

private float CalculateTotalInventoryWeight()
{
float totalWeight = 0f;

foreach (Item item in inventory)
{
totalWeight += item.weight;
}

return totalWeight;
}
}

In this example, we create three custom structs:

  • Item to represent a game item
  • PlayerStats to represent player statistics
  • LevelData to represent a game level

These structs are used to store and manipulate game data in a structured way.

Mutable vs. Immutable Structs

While structs can have methods that modify their fields, it's generally considered a best practice to make structs immutable (i.e., their state cannot be changed after creation). This is because the value semantics of structs can lead to unexpected behavior when they are mutable.

Here's an example of an immutable struct:

public readonly struct ImmutableVector2
{
public readonly float x;
public readonly float y;

public ImmutableVector2(float x, float y)
{
this.x = x;
this.y = y;
}

public float Magnitude()
{
return Mathf.Sqrt(x * x + y * y);
}

public ImmutableVector2 Normalized()
{
float mag = Magnitude();
if (mag > 0)
{
return new ImmutableVector2(x / mag, y / mag);
}
return this;
}

public ImmutableVector2 Add(ImmutableVector2 other)
{
return new ImmutableVector2(x + other.x, y + other.y);
}

public ImmutableVector2 Multiply(float scalar)
{
return new ImmutableVector2(x * scalar, y * scalar);
}

public override string ToString()
{
return $"({x}, {y})";
}
}

In this example, the ImmutableVector2 struct is immutable because:

  • Its fields are marked as readonly, which prevents them from being modified after construction
  • Methods that would normally modify the struct (like Normalized, Add, and Multiply) instead return a new instance with the modified values

Using immutable structs can help avoid bugs related to unexpected value copying and can make your code more predictable.

Structs and Memory Layout

Structs have a predictable memory layout, which can be useful for interoperability with native code or for optimizing memory usage. You can control the memory layout of a struct using the StructLayout attribute:

using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Sequential)]
public struct VertexData
{
public Vector3 position;
public Vector3 normal;
public Vector2 uv;
public Color color;
}

In this example, the StructLayout attribute with LayoutKind.Sequential ensures that the fields of the struct are laid out in memory in the order they are declared. This can be important when passing struct data to native code or when working with graphics APIs.

Ref Structs

Starting with C# 7.2, you can declare a struct as a ref struct, which restricts it to only being used on the stack:

public ref struct StackOnlyStruct
{
public int value;

public StackOnlyStruct(int value)
{
this.value = value;
}
}

Ref structs have several restrictions:

  • They can only be used as local variables or method parameters
  • They cannot be used as fields in classes or non-ref structs
  • They cannot be used in async methods or lambda expressions
  • They cannot be boxed (converted to reference types)

Ref structs are useful for high-performance scenarios where you want to ensure that a struct is never allocated on the heap.

Readonly Ref Structs

You can combine the readonly and ref modifiers to create a readonly ref struct, which is both immutable and stack-only:

public readonly ref struct ImmutableStackOnlyStruct
{
public readonly int value;

public ImmutableStackOnlyStruct(int value)
{
this.value = value;
}
}

Readonly ref structs have all the restrictions of ref structs, plus they cannot have mutable fields or methods that modify their state.

Practical Examples

Example 1: Game Configuration

// Struct to represent game settings
public struct GameSettings
{
public float masterVolume;
public float musicVolume;
public float sfxVolume;
public int resolutionWidth;
public int resolutionHeight;
public bool fullscreen;
public int qualityLevel;
public float mouseSensitivity;

public GameSettings(float masterVolume, float musicVolume, float sfxVolume, int resolutionWidth, int resolutionHeight, bool fullscreen, int qualityLevel, float mouseSensitivity)
{
this.masterVolume = Mathf.Clamp01(masterVolume);
this.musicVolume = Mathf.Clamp01(musicVolume);
this.sfxVolume = Mathf.Clamp01(sfxVolume);
this.resolutionWidth = Mathf.Max(640, resolutionWidth);
this.resolutionHeight = Mathf.Max(480, resolutionHeight);
this.fullscreen = fullscreen;
this.qualityLevel = Mathf.Clamp(qualityLevel, 0, QualitySettings.names.Length - 1);
this.mouseSensitivity = Mathf.Clamp(mouseSensitivity, 0.1f, 10f);
}

public static GameSettings Default => new GameSettings(1f, 0.8f, 1f, 1920, 1080, true, 2, 1f);
}

// 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(masterVolume, musicVolume, sfxVolume, resolutionWidth, resolutionHeight, fullscreen, qualityLevel, mouseSensitivity);

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 = GameSettings.Default;
SaveSettings();
ApplySettings();
}

public void SetMasterVolume(float volume)
{
GameSettings newSettings = currentSettings;
newSettings.masterVolume = Mathf.Clamp01(volume);
currentSettings = newSettings;
}

public void SetMusicVolume(float volume)
{
GameSettings newSettings = currentSettings;
newSettings.musicVolume = Mathf.Clamp01(volume);
currentSettings = newSettings;
}

public void SetSFXVolume(float volume)
{
GameSettings newSettings = currentSettings;
newSettings.sfxVolume = Mathf.Clamp01(volume);
currentSettings = newSettings;
}

public void SetResolution(int width, int height)
{
GameSettings newSettings = currentSettings;
newSettings.resolutionWidth = Mathf.Max(640, width);
newSettings.resolutionHeight = Mathf.Max(480, height);
currentSettings = newSettings;
}

public void SetFullscreen(bool fullscreen)
{
GameSettings newSettings = currentSettings;
newSettings.fullscreen = fullscreen;
currentSettings = newSettings;
}

public void SetQualityLevel(int level)
{
GameSettings newSettings = currentSettings;
newSettings.qualityLevel = Mathf.Clamp(level, 0, QualitySettings.names.Length - 1);
currentSettings = newSettings;
}

public void SetMouseSensitivity(float sensitivity)
{
GameSettings newSettings = currentSettings;
newSettings.mouseSensitivity = Mathf.Clamp(sensitivity, 0.1f, 10f);
currentSettings = newSettings;
}

public GameSettings GetCurrentSettings()
{
return currentSettings;
}
}

In this example, we use a GameSettings struct to represent the game's configuration settings. The struct is used to store and manipulate the settings in a structured way, and the SettingsManager class provides methods for loading, saving, and applying the settings.

Example 2: Tile-Based Game

// Struct to represent a tile position
public struct TilePosition
{
public int x;
public int y;

public TilePosition(int x, int y)
{
this.x = x;
this.y = y;
}

public Vector3 ToWorldPosition(float tileSize)
{
return new Vector3(x * tileSize, 0, y * tileSize);
}

public static TilePosition FromWorldPosition(Vector3 worldPosition, float tileSize)
{
int x = Mathf.FloorToInt(worldPosition.x / tileSize);
int y = Mathf.FloorToInt(worldPosition.z / tileSize);
return new TilePosition(x, y);
}

public float DistanceTo(TilePosition other)
{
int dx = other.x - x;
int dy = other.y - y;
return Mathf.Sqrt(dx * dx + dy * dy);
}

public int ManhattanDistanceTo(TilePosition other)
{
int dx = Mathf.Abs(other.x - x);
int dy = Mathf.Abs(other.y - y);
return dx + dy;
}

public override string ToString()
{
return $"({x}, {y})";
}

public override bool Equals(object obj)
{
if (obj is TilePosition other)
{
return x == other.x && y == other.y;
}
return false;
}

public override int GetHashCode()
{
return x.GetHashCode() ^ y.GetHashCode();
}

public static bool operator ==(TilePosition a, TilePosition b)
{
return a.x == b.x && a.y == b.y;
}

public static bool operator !=(TilePosition a, TilePosition b)
{
return a.x != b.x || a.y != b.y;
}

public static TilePosition operator +(TilePosition a, TilePosition b)
{
return new TilePosition(a.x + b.x, a.y + b.y);
}

public static TilePosition operator -(TilePosition a, TilePosition b)
{
return new TilePosition(a.x - b.x, a.y - b.y);
}
}

// Struct to represent a tile
public struct Tile
{
public TileType type;
public bool isWalkable;
public float movementCost;

public Tile(TileType type)
{
this.type = type;

// Set properties based on tile type
switch (type)
{
case TileType.Grass:
isWalkable = true;
movementCost = 1.0f;
break;
case TileType.Water:
isWalkable = false;
movementCost = float.PositiveInfinity;
break;
case TileType.Road:
isWalkable = true;
movementCost = 0.8f;
break;
case TileType.Mountain:
isWalkable = true;
movementCost = 2.0f;
break;
case TileType.Forest:
isWalkable = true;
movementCost = 1.5f;
break;
default:
isWalkable = true;
movementCost = 1.0f;
break;
}
}

public Color GetColor()
{
switch (type)
{
case TileType.Grass:
return Color.green;
case TileType.Water:
return Color.blue;
case TileType.Road:
return Color.gray;
case TileType.Mountain:
return new Color(0.5f, 0.5f, 0.5f); // Dark gray
case TileType.Forest:
return new Color(0.0f, 0.5f, 0.0f); // Dark green
default:
return Color.white;
}
}
}

// Enum to represent tile types
public enum TileType
{
Grass,
Water,
Road,
Mountain,
Forest
}

// Grid manager
public class GridManager : MonoBehaviour
{
[SerializeField] private int width = 10;
[SerializeField] private int height = 10;
[SerializeField] private float tileSize = 1.0f;
[SerializeField] private GameObject tilePrefab;

private Tile[,] grid;
private GameObject[,] tileObjects;

private void Awake()
{
// Initialize the grid
grid = new Tile[width, height];
tileObjects = new GameObject[width, height];

// Generate a random grid
GenerateRandomGrid();

// Create the visual representation
CreateVisualGrid();
}

private void GenerateRandomGrid()
{
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
// Generate a random tile type
TileType type = (TileType)UnityEngine.Random.Range(0, System.Enum.GetValues(typeof(TileType)).Length);

// Create a tile with the random type
grid[x, y] = new Tile(type);
}
}
}

private void CreateVisualGrid()
{
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
// Create a tile object
TilePosition position = new TilePosition(x, y);
Vector3 worldPosition = position.ToWorldPosition(tileSize);

GameObject tileObject = Instantiate(tilePrefab, worldPosition, Quaternion.identity, transform);
tileObject.name = $"Tile_{x}_{y}";

// Set the tile's color based on its type
Renderer renderer = tileObject.GetComponent<Renderer>();
if (renderer != null)
{
renderer.material.color = grid[x, y].GetColor();
}

// Store the tile object
tileObjects[x, y] = tileObject;
}
}
}

public Tile GetTile(TilePosition position)
{
if (IsValidPosition(position))
{
return grid[position.x, position.y];
}

// Return a default tile (water) for invalid positions
return new Tile(TileType.Water);
}

public bool IsValidPosition(TilePosition position)
{
return position.x >= 0 && position.x < width && position.y >= 0 && position.y < height;
}

public bool IsWalkable(TilePosition position)
{
return IsValidPosition(position) && grid[position.x, position.y].isWalkable;
}

public float GetMovementCost(TilePosition position)
{
if (IsValidPosition(position))
{
return grid[position.x, position.y].movementCost;
}

return float.PositiveInfinity;
}

public TilePosition WorldToGrid(Vector3 worldPosition)
{
return TilePosition.FromWorldPosition(worldPosition, tileSize);
}

public Vector3 GridToWorld(TilePosition gridPosition)
{
return gridPosition.ToWorldPosition(tileSize);
}

public List<TilePosition> GetNeighbors(TilePosition position)
{
List<TilePosition> neighbors = new List<TilePosition>();

// Check the four adjacent tiles
TilePosition[] adjacentPositions = new TilePosition[]
{
new TilePosition(position.x + 1, position.y),
new TilePosition(position.x - 1, position.y),
new TilePosition(position.x, position.y + 1),
new TilePosition(position.x, position.y - 1)
};

foreach (TilePosition adjacentPosition in adjacentPositions)
{
if (IsValidPosition(adjacentPosition) && IsWalkable(adjacentPosition))
{
neighbors.Add(adjacentPosition);
}
}

return neighbors;
}

public List<TilePosition> FindPath(TilePosition start, TilePosition end)
{
// Simple A* pathfinding
List<TilePosition> path = new List<TilePosition>();

if (!IsValidPosition(start) || !IsValidPosition(end) || !IsWalkable(start) || !IsWalkable(end))
{
return path;
}

// The set of nodes already evaluated
HashSet<TilePosition> closedSet = new HashSet<TilePosition>();

// The set of currently discovered nodes that are not evaluated yet
List<TilePosition> openSet = new List<TilePosition>();
openSet.Add(start);

// For each node, which node it can most efficiently be reached from
Dictionary<TilePosition, TilePosition> cameFrom = new Dictionary<TilePosition, TilePosition>();

// For each node, the cost of getting from the start node to that node
Dictionary<TilePosition, float> gScore = new Dictionary<TilePosition, float>();
gScore[start] = 0;

// For each node, the total cost of getting from the start node to the goal by passing by that node
Dictionary<TilePosition, float> fScore = new Dictionary<TilePosition, float>();
fScore[start] = start.ManhattanDistanceTo(end);

while (openSet.Count > 0)
{
// Find the node in openSet with the lowest fScore value
TilePosition current = openSet[0];
float lowestFScore = fScore.ContainsKey(current) ? fScore[current] : float.PositiveInfinity;

for (int i = 1; i < openSet.Count; i++)
{
float score = fScore.ContainsKey(openSet[i]) ? fScore[openSet[i]] : float.PositiveInfinity;
if (score < lowestFScore)
{
lowestFScore = score;
current = openSet[i];
}
}

// If we've reached the end, reconstruct the path
if (current.Equals(end))
{
while (cameFrom.ContainsKey(current))
{
path.Insert(0, current);
current = cameFrom[current];
}

path.Insert(0, start);
return path;
}

openSet.Remove(current);
closedSet.Add(current);

foreach (TilePosition neighbor in GetNeighbors(current))
{
if (closedSet.Contains(neighbor))
{
continue;
}

float tentativeGScore = gScore[current] + GetMovementCost(neighbor);

if (!openSet.Contains(neighbor))
{
openSet.Add(neighbor);
}
else if (tentativeGScore >= (gScore.ContainsKey(neighbor) ? gScore[neighbor] : float.PositiveInfinity))
{
continue;
}

cameFrom[neighbor] = current;
gScore[neighbor] = tentativeGScore;
fScore[neighbor] = gScore[neighbor] + neighbor.ManhattanDistanceTo(end);
}
}

return path;
}

public void HighlightPath(List<TilePosition> path)
{
// Reset all tiles to their original color
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
Renderer renderer = tileObjects[x, y].GetComponent<Renderer>();
if (renderer != null)
{
renderer.material.color = grid[x, y].GetColor();
}
}
}

// Highlight the path
foreach (TilePosition position in path)
{
if (IsValidPosition(position))
{
Renderer renderer = tileObjects[position.x, position.y].GetComponent<Renderer>();
if (renderer != null)
{
renderer.material.color = Color.yellow;
}
}
}
}
}

// Player controller for the grid-based game
public class GridPlayerController : MonoBehaviour
{
[SerializeField] private GridManager gridManager;
[SerializeField] private float moveSpeed = 5f;

private TilePosition currentPosition;
private List<TilePosition> currentPath;
private int currentPathIndex;
private bool isMoving;

private void Start()
{
// Get the player's starting position on the grid
currentPosition = gridManager.WorldToGrid(transform.position);

// Snap the player to the grid
transform.position = gridManager.GridToWorld(currentPosition);

currentPath = new List<TilePosition>();
currentPathIndex = 0;
isMoving = false;
}

private void Update()
{
// Handle input
if (Input.GetMouseButtonDown(0) && !isMoving)
{
// Cast a ray from the mouse position
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;

if (Physics.Raycast(ray, out hit))
{
// Convert the hit position to a grid position
TilePosition targetPosition = gridManager.WorldToGrid(hit.point);

// Find a path to the target position
currentPath = gridManager.FindPath(currentPosition, targetPosition);

// Highlight the path
gridManager.HighlightPath(currentPath);

if (currentPath.Count > 0)
{
// Start moving along the path
currentPathIndex = 0;
isMoving = true;
}
}
}

// Move along the path
if (isMoving && currentPath.Count > 0)
{
// Get the next position in the path
TilePosition targetPosition = currentPath[currentPathIndex];
Vector3 targetWorldPosition = gridManager.GridToWorld(targetPosition);

// Move towards the target position
transform.position = Vector3.MoveTowards(transform.position, targetWorldPosition, moveSpeed * Time.deltaTime);

// If we've reached the target position, move to the next one
if (Vector3.Distance(transform.position, targetWorldPosition) < 0.01f)
{
currentPosition = targetPosition;
currentPathIndex++;

if (currentPathIndex >= currentPath.Count)
{
// We've reached the end of the path
isMoving = false;
}
}
}
}
}

In this example, we use two custom structs:

  • TilePosition to represent a position on the grid
  • Tile to represent a tile on the grid

These structs are used to store and manipulate grid data in a structured way, and the GridManager class provides methods for working with the grid.

Example 3: Particle System

// Struct to represent a particle
public struct Particle
{
public Vector3 position;
public Vector3 velocity;
public float lifetime;
public float maxLifetime;
public float size;
public Color color;

public Particle(Vector3 position, Vector3 velocity, float lifetime, float size, Color color)
{
this.position = position;
this.velocity = velocity;
this.lifetime = lifetime;
this.maxLifetime = lifetime;
this.size = size;
this.color = color;
}

public void Update(float deltaTime, Vector3 gravity)
{
// Update velocity
velocity += gravity * deltaTime;

// Update position
position += velocity * deltaTime;

// Update lifetime
lifetime -= deltaTime;
}

public float NormalizedLifetime => Mathf.Clamp01(lifetime / maxLifetime);

public bool IsAlive => lifetime > 0;
}

// Custom particle system
public class SimpleParticleSystem : MonoBehaviour
{
[SerializeField] private int maxParticles = 1000;
[SerializeField] private float particleLifetime = 2f;
[SerializeField] private float particleSize = 0.1f;
[SerializeField] private float emissionRate = 10f;
[SerializeField] private float initialSpeed = 2f;
[SerializeField] private Vector3 gravity = new Vector3(0, -9.81f, 0);
[SerializeField] private Gradient colorOverLifetime;
[SerializeField] private AnimationCurve sizeOverLifetime;
[SerializeField] private Material particleMaterial;

private Particle[] particles;
private int particleCount;
private float emissionAccumulator;
private Mesh particleMesh;

private void Awake()
{
// Initialize particles
particles = new Particle[maxParticles];
particleCount = 0;
emissionAccumulator = 0f;

// Create a quad mesh for the particles
particleMesh = new Mesh();
particleMesh.vertices = new Vector3[]
{
new Vector3(-0.5f, -0.5f, 0),
new Vector3(0.5f, -0.5f, 0),
new Vector3(0.5f, 0.5f, 0),
new Vector3(-0.5f, 0.5f, 0)
};
particleMesh.triangles = new int[] { 0, 1, 2, 0, 2, 3 };
particleMesh.uv = new Vector2[]
{
new Vector2(0, 0),
new Vector2(1, 0),
new Vector2(1, 1),
new Vector2(0, 1)
};
}

private void Update()
{
// Emit new particles
EmitParticles();

// Update existing particles
UpdateParticles();

// Render particles
RenderParticles();
}

private void EmitParticles()
{
// Calculate how many particles to emit this frame
emissionAccumulator += emissionRate * Time.deltaTime;
int particlesToEmit = Mathf.FloorToInt(emissionAccumulator);
emissionAccumulator -= particlesToEmit;

// Emit particles
for (int i = 0; i < particlesToEmit; i++)
{
if (particleCount >= maxParticles)
{
break;
}

// Generate random direction
Vector3 direction = Random.onUnitSphere;

// Create a new particle
Particle particle = new Particle(
transform.position,
direction * initialSpeed,
particleLifetime,
particleSize,
colorOverLifetime.Evaluate(1f)
);

// Add the particle to the array
particles[particleCount] = particle;
particleCount++;
}
}

private void UpdateParticles()
{
int aliveCount = 0;

for (int i = 0; i < particleCount; i++)
{
Particle particle = particles[i];

// Update the particle
particle.Update(Time.deltaTime, gravity);

// Check if the particle is still alive
if (particle.IsAlive)
{
// Update the particle's color and size based on its lifetime
particle.color = colorOverLifetime.Evaluate(particle.NormalizedLifetime);
particle.size = particleSize * sizeOverLifetime.Evaluate(particle.NormalizedLifetime);

// Keep the particle
particles[aliveCount] = particle;
aliveCount++;
}
}

// Update the particle count
particleCount = aliveCount;
}

private void RenderParticles()
{
// Skip rendering if there are no particles
if (particleCount == 0 || particleMaterial == null)
{
return;
}

// Prepare matrices, colors, and sizes for instanced rendering
Matrix4x4[] matrices = new Matrix4x4[particleCount];
Color[] colors = new Color[particleCount];

for (int i = 0; i < particleCount; i++)
{
Particle particle = particles[i];

// Calculate the particle's transform matrix
Matrix4x4 matrix = Matrix4x4.TRS(
particle.position,
Quaternion.LookRotation(Camera.main.transform.forward, Camera.main.transform.up),
new Vector3(particle.size, particle.size, particle.size)
);

matrices[i] = matrix;
colors[i] = particle.color;
}

// Set the material properties
MaterialPropertyBlock propertyBlock = new MaterialPropertyBlock();
propertyBlock.SetVectorArray("_Color", System.Array.ConvertAll(colors, color => (Vector4)color));

// Draw the particles
Graphics.DrawMeshInstanced(particleMesh, 0, particleMaterial, matrices, particleCount, propertyBlock);
}
}

In this example, we use a Particle struct to represent a particle in a simple particle system. The struct is used to store and update particle data, and the SimpleParticleSystem class manages a collection of particles.

Best Practices for Structs

  1. Keep Structs Small: Structs are most efficient when they are small (less than 16 bytes). If your struct is larger, consider using a class instead.

  2. Make Structs Immutable When Possible: Immutable structs are easier to reason about and less prone to bugs related to value copying.

  3. Override Equals and GetHashCode: If your struct will be used in collections or compared for equality, override these methods to provide proper equality comparison.

  4. Implement Operator Overloads: If your struct represents a value that can be manipulated mathematically, implement appropriate operator overloads.

  5. Use readonly Fields: Mark fields as readonly to prevent them from being modified after construction.

  6. Avoid Boxing: Boxing (converting a struct to a reference type) can be expensive. Avoid using structs in contexts where they will be boxed frequently.

  7. Be Careful with Mutable Structs: If you must make a struct mutable, be aware of the potential for unexpected behavior due to value copying.

  8. Consider Performance Implications: Passing large structs by value can be expensive. Consider using ref or in parameters for large structs.

  9. Use Structs for Simple Data: Structs are best for simple data structures that primarily store data and don't have complex behavior.

  10. Document Struct Behavior: Clearly document the behavior of your structs, especially if they are mutable or have unusual semantics.

Conclusion

Structs are a powerful feature in C# that allow you to create lightweight value types for efficient data storage and manipulation. They are particularly useful in game development for representing simple data structures that don't require the overhead of classes.

Key points to remember about structs:

  • Structs are value types, not reference types
  • Structs are stored on the stack, not the heap
  • Structs are passed by value, not by reference
  • Structs cannot inherit from other structs or classes
  • Structs can implement interfaces
  • Structs are most efficient when they are small and immutable

By using structs effectively, you can create more efficient and maintainable code for your Unity games.

In the next section, we'll explore records, which are a newer feature in C# that provide a concise way to define immutable reference types.

Practice Exercise

Exercise: Design a simple weather system for a game with the following requirements:

  1. Create a WeatherData struct with:

    • Properties for temperature, humidity, wind speed, and precipitation
    • Methods for calculating "feels like" temperature and weather condition
  2. Create a WeatherSystem class that:

    • Generates realistic weather data based on the current season and time of day
    • Smoothly transitions between different weather conditions
    • Applies visual effects based on the current weather
  3. Create a WeatherEffect struct that:

    • Defines the visual effects for a specific weather condition
    • Includes properties for fog density, cloud coverage, particle effects, etc.

Think about how structs help you represent and manipulate weather data efficiently in your game.