No history yet

Advanced C# Scripting

Smarter Memory Management

Constantly creating and destroying objects in a game, like bullets or enemies, can be surprisingly expensive. Every time you call Instantiate(), Unity allocates memory. When you call Destroy(), that memory is marked for cleanup. This process, called garbage collection, can cause noticeable hitches or stutters in your game, especially on mobile devices.

The solution is to recycle objects instead of creating new ones. This technique is called object pooling.

Think of it like a restaurant. Instead of manufacturing a new plate for every single customer and then throwing it away, the restaurant keeps a stack of clean plates. When a customer needs one, a plate is taken from the stack. When they're done, the plate is washed and returned to the stack, ready for the next person.

An object pool works the same way. At the start of a level, you instantiate a number of objects (e.g., 50 bullets) and immediately deactivate them. When you need a bullet, you grab one from the pool, activate it, and place it where it needs to go. Once it hits a target or goes off-screen, you don't destroy it. You simply deactivate it and return it to the pool.

using System.Collections.Generic;
using UnityEngine;

public class SimpleObjectPool : MonoBehaviour
{
    public GameObject objectToPool;
    public int amountToPool;

    private List<GameObject> pooledObjects;

    void Start()
    {
        pooledObjects = new List<GameObject>();
        for (int i = 0; i < amountToPool; i++)
        {
            GameObject obj = Instantiate(objectToPool);
            obj.SetActive(false);
            pooledObjects.Add(obj);
        }
    }

    public GameObject GetPooledObject()
    {
        // Find an inactive object in the pool
        for (int i = 0; i < pooledObjects.Count; i++)
        {
            if (!pooledObjects[i].activeInHierarchy)
            {
                return pooledObjects[i];
            }
        }
        // Optional: If no inactive object is found, create a new one
        // GameObject obj = Instantiate(objectToPool);
        // pooledObjects.Add(obj);
        // return obj;

        return null; // Or expand the pool
    }
}

This simple script creates a pool of objects when the game starts. The GetPooledObject() method cycles through the list to find one that's available. By reusing objects, you avoid the performance cost of frequent memory allocation and deallocation, leading to smoother gameplay.

Rethinking Game Architecture

Unity's standard workflow revolves around GameObjects and MonoBehaviour components. This object-oriented approach is intuitive and works great for many games. However, when you need to manage thousands of objects at once, it can show its limits. The way MonoBehaviour components store data can be scattered in memory, leading to inefficient processing by the CPU.

Enter the Entity Component System, or ECS. It's a different way of thinking about your game's architecture that prioritizes performance by organizing data efficiently.

ECS separates data from logic. This is the core principle that makes it so powerful.

ECS has three parts:

  • Entity: A simple identifier, like a serial number. It doesn't hold any data or logic itself; it just represents a single "thing" in your game.
  • Component: Pure data. A component is just a struct containing variables, like Position, Velocity, or Health. It has no methods or game logic.
  • System: Pure logic. A system contains the code that acts on entities with a specific set of components. For example, a MovementSystem would find all entities that have both a Position and a Velocity component and update their position each frame.

The magic of ECS is in how it arranges memory. All components of the same type are stored together in tightly packed arrays. When a system runs, it can iterate through this linear block of memory very quickly. This is extremely friendly to the CPU's cache, resulting in massive performance gains for games with large numbers of entities.

Maximum Performance with Burst

Once you're working with ECS, you can unlock even more performance with the Burst compiler. Burst is a specialized compiler that takes your C# code and transforms it into highly optimized machine code, tailored to the specific hardware it's running on.

Burst works best on code that involves a lot of mathematical calculations, like physics, AI, or procedural generation. It's designed to work with a specific subset of C# that doesn't involve managed objects or other complex features that can slow things down. This makes it a perfect fit for the data-oriented systems in an ECS architecture.

To use it, you simply add an attribute to your code.

using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;

// Add the [BurstCompile] attribute to a system's job.
[BurstCompile]
public partial struct MovementJob : IJobEntity
{
    public float DeltaTime;

    // The job's Execute method runs on entities that have
    // both Position and Velocity components.
    void Execute(ref Position position, in Velocity velocity)
    {
        position.Value += velocity.Value * DeltaTime;
    }
}

With the [BurstCompile] attribute, Unity's compiler will generate extremely fast, low-level code for your job. The performance increase can be dramatic, allowing you to simulate tens of thousands of entities at once with a smooth frame rate.

Keeping Code Clean

As projects grow, keeping your code organized is crucial for maintainability. A messy codebase is difficult to debug, update, and expand. Adopting good structuring practices early on will save you headaches later.

One of the most effective principles is the Single Responsibility Principle. Each class or method should do exactly one thing.

Instead of one massive PlayerController script that handles movement, combat, inventory, and health, break it down into smaller, focused components:

  • PlayerMovement: Handles input and moves the character.
  • PlayerCombat: Manages attacking and taking damage.
  • PlayerInventory: Controls picking up and using items.
  • PlayerHealth: Tracks the player's health and handles death.

This approach makes your code easier to understand. If there's a bug with player movement, you know exactly where to look. It also promotes reusability. You could potentially use the PlayerHealth script on an enemy with little or no modification.

Another key practice is to use namespaces to organize your code into logical groups. This prevents class names from conflicting and makes it clear which part of the project a script belongs to.

namespace MyGame.Player
{
    public class PlayerMovement : MonoBehaviour
    {
        // Movement code here
    }
}

namespace MyGame.Enemy
{
    public class EnemyAI : MonoBehaviour
    {
        // AI code here
    }
}

These techniques aren't about flashy performance gains. They're about creating a codebase that you and your team can work with efficiently for the long term.

Quiz Questions 1/5

What is the primary performance problem that object pooling is designed to solve in Unity?

Quiz Questions 2/5

In an Entity Component System (ECS) architecture, what is the defining characteristic of a 'Component'?

By combining efficient memory management, data-oriented design, and clean coding practices, you can build complex, high-performance games in Unity that are also a pleasure to maintain.