Building Your Own Systems

How to design small, reusable pieces of game code, shown by building your own object pool and cooldown timer from scratch.

A good system is like a vending machine. It has a few clear buttons on the front (put money in, pick a snack), and you never need to open it up to use it. Inside it can be as complicated as it likes. Your other scripts should use your systems the same way: press a button, get a result, never reach inside.

What counts as a "system"?

Any piece of code that does one job for the rest of your game: spawning and reusing objects, cooldowns, saving, playing sounds, tracking score. Unity gives you some of these already (like ObjectPool), but building your own teaches you how they work and lets you shape them to your game.

Five rules for a good system

  1. One job. A pool reuses objects. It doesn't also play sounds and track score.
  2. Few, clear buttons. Keep the public methods small: Get(), Release(). Make everything else private.
  3. Doesn't know about your game. A pool shouldn't know what a "Zombie" is. That way you can reuse it in your next project.
  4. Plain C# if it doesn't need Unity. A cooldown timer doesn't need to be on a GameObject, so don't make it a MonoBehaviour.
  5. Start simple. Build the smallest version that works, use it, and add features only when you actually need them.

Build 1: your own object pool

Instead of destroying bullets and creating new ones, we'll switch them off and keep them on a shelf for next time. (Why this helps: Object Pooling.)

Get()Shelf empty? Make oneUse itRelease()Back on the shelf
The shelf is a Queue: first in, first out.

Step 1: a way for objects to reset themselves

A reused bullet still has its old speed and position. We'll give poolable objects two "hooks" to reset themselves. An interface is a promise that a class has certain methods:

public interface IPoolable
{
    void OnTakenFromPool();   // reset here: health, speed, timers...
    void OnReturnedToPool();  // clean up here: stop sounds, clear targets...
}

Step 2: the pool

using System.Collections.Generic;
using UnityEngine;

public class SimplePool<T> where T : Component
{
    private readonly T prefab;
    private readonly Transform parent;
    private readonly Queue<T> shelf = new Queue<T>();

    public SimplePool(T prefab, int startCount, Transform parent = null)
    {
        this.prefab = prefab;
        this.parent = parent;
        for (int i = 0; i < startCount; i++)
            shelf.Enqueue(CreateNew()); // make some up front, during loading
    }

    public T Get(Vector3 position, Quaternion rotation)
    {
        T item = shelf.Count > 0 ? shelf.Dequeue() : CreateNew();
        item.transform.SetPositionAndRotation(position, rotation);
        item.gameObject.SetActive(true);
        if (item is IPoolable p) p.OnTakenFromPool();
        return item;
    }

    public void Release(T item)
    {
        if (!item.gameObject.activeSelf) return;   // already on the shelf
        if (item is IPoolable p) p.OnReturnedToPool();
        item.gameObject.SetActive(false);
        shelf.Enqueue(item);
    }

    private T CreateNew()
    {
        T item = Object.Instantiate(prefab, parent);
        item.gameObject.SetActive(false);
        return item;
    }
}

A few things to notice:

Step 3: use it

using UnityEngine;

public class Gun : MonoBehaviour
{
    [SerializeField] private Bullet bulletPrefab;
    private SimplePool<Bullet> pool;

    void Awake() => pool = new SimplePool<Bullet>(bulletPrefab, 20);

    public void Fire()
    {
        Bullet b = pool.Get(transform.position, transform.rotation);
        b.Launch(pool);   // tell the bullet which pool to go back to
    }
}
using UnityEngine;

public class Bullet : MonoBehaviour, IPoolable
{
    [SerializeField] private float speed = 30f;
    [SerializeField] private float lifetime = 2f;

    private SimplePool<Bullet> pool;
    private float timeLeft;

    public void Launch(SimplePool<Bullet> owner) => pool = owner;

    public void OnTakenFromPool()  { timeLeft = lifetime; }  // fresh start
    public void OnReturnedToPool() { }                       // nothing to clean up yet

    void Update()
    {
        transform.position += transform.forward * speed * Time.deltaTime;
        timeLeft -= Time.deltaTime;
        if (timeLeft <= 0) pool.Release(this);  // instead of Destroy(gameObject)
    }
}

Build 2: a cooldown timer

Almost every game needs "you can only do this once every X seconds": dashes, attacks, abilities. Instead of writing timer code in every script, write it once:

using UnityEngine;

public class Cooldown
{
    private readonly float duration;
    private float readyAt;

    public Cooldown(float seconds) => duration = seconds;

    public bool IsReady => Time.time >= readyAt;
    public float Remaining => Mathf.Max(0f, readyAt - Time.time);
    public float Progress01 => duration <= 0 ? 1f : 1f - Remaining / duration; // for UI bars

    // Returns true (and starts the cooldown) only if it was ready
    public bool TryUse()
    {
        if (!IsReady) return false;
        readyAt = Time.time + duration;
        return true;
    }
}

Now any script gets a cooldown in two lines:

// needs: using UnityEngine.InputSystem;
private readonly Cooldown dash = new Cooldown(1.5f);

void Update()
{
    if (Keyboard.current.leftShiftKey.wasPressedThisFrame && dash.TryUse())
        Dash();
}

Notice how this follows the rules: one job, three simple buttons (IsReady, TryUse, Remaining), plain C#, and it knows nothing about dashing.

Where to put shared systems

ApproachWhen
A field on the script that uses itOnly one script needs it, like the Gun's pool. Simplest; start here.
A ScriptableObject assetSeveral objects share data or events, set up in the Inspector.
A single manager in the scene (a "singleton")Truly global things like an audio manager. Use sparingly: it makes everything depend on it.

More systems worth building yourself

Common Mistakes

See also