Back to Home

Ocean’s Chronicles

Project Type:

Game Design and Development

Technologies:

Unity, Custom Engine, Figma, GitHub

Languages:

C#

Introduction and Project Timeline

Ocean’s Chronicles is a whimsical yet dark 1st-person shooter where fishes can wield guns. In this penultimate year project in university, I was the Design lead in a team of 9 people. I led the progress of gameplay logic, mechanics, story, and level design with 2 other designers. We collaborated with the computer science students who focused on creating the custom engine.

The first half of the project was done in Unity as a proof of concept. The second half was developed in a custom game engine developed by the programmers. I scripted the mechanics in both game engines, such as shooting and UI interactions, using C#.

Coding Design Patterns

I utilised various coding design patterns to optimise the gameplay. One of the requirements was to ensure that the game is optimised for a minimum of 60FPS when played at 1920×1080 resolution. To achieve that, I referenced Refactoring Guru, who I was made aware of by my lecturer during my Advanced Scripting class in Year 2.

My proudest implementation was the Object Pooling, which resulted in an improvement in performance by a significant amount.

Object Pooling

As Ocean Chronicles is a shooter game, the repeated creation and destruction of game objects is absolutely necessary, particularly projectiles like bullets. Unity can definitely handle large amounts of creation and destruction of game objects even without optimisation, but I had to think forward and prepare for the custom engine, which may not be as optimised as my CS teammates, who only have a few weeks to work on it.

Resource Pool, more commonly known as Object Pool in game development, was one pattern I decided to implement. The core concept of Object Pooling is simple: reuse objects instead of constantly creating and destroying them.

To manage this pooling system, I created a central script called ObjectPooler. This script acts as the manager for all my object pools in the game.

public class ObjectPooler : MonoBehaviour
{
    // Singleton instance
    public static ObjectPooler Instance;

    [System.Serializable]
    public class Pool
    {
        public string tag;
        public GameObject prefab;
        public int size;
        public bool canGrow = true;
    }

    public List<Pool> pools;
    private Dictionary<string, Queue<GameObject>> poolDictionary;
}

I decided to use the Singleton pattern (Instance) so I could easily access the ObjectPooler game code from anywhere without dragging and dropping references every time. It ensures there’s only one ObjectPooler managing everything.

I also created a Pool class. This is helpful because it allows me to define different objects I want to pool directly in the Unity Inspector. For each pool, I can specify a tag (like “Bullet”, “EnemyBullet”, etc.), the prefab of the object to pool, the initial size of the pool, and whether the pool can grow if it runs out of objects. This makes setting up and managing pools very flexible and user-friendly within the Unity editor. I use a List<Pool> pools to hold all these different pool configurations and a Dictionary<string, Queue<GameObject>> poolDictionary to store the actual pools at runtime, organised by their tags.

Pool Initialisation:

private void Awake()
{
    if (Instance == null) Instance = this; else { Destroy(gameObject); return; }
    poolDictionary = new Dictionary<string, Queue<GameObject>>();
    foreach (Pool pool in pools)
    {
        Queue<GameObject> objectPool = new Queue<GameObject>();
        for (int i = 0; i < pool.size; i++)
        {
            GameObject obj = Instantiate(pool.prefab);
            obj.SetActive(false);
            objectPool.Enqueue(obj);
        }
        poolDictionary.Add(pool.tag, objectPool);
    }
}

In the Awake() function, which runs when the game starts, I initialize the Singleton and then set up my poolDictionary. For each Pool I defined in the Inspector, I create a Queue<GameObject>. A Queue is a perfect data structure for this because it works like a line – objects are added to the back (enqueued) and removed from the front (dequeued), which is exactly how we want to manage our pool of reusable objects (First-In, First-Out or FIFO concept).

Then, for each pool, I pre-instantiate the number of objects specified by pool.size, deactivate them using obj.SetActive(false) (so they’re not visible or active in the scene initially), and then add them to the Queue using objectPool.Enqueue(obj). Finally, I add this Queue to my poolDictionary with the pool.tag as the key, so I can easily access the correct pool later.

Spawning Objects from Pool:

public GameObject SpawnFromPool(string tag, Vector3 position, Quaternion rotation)
{
    if (!poolDictionary.ContainsKey(tag)) { Debug.LogWarning(...); return null; }
    Queue<GameObject> objectPool = poolDictionary[tag];
    if (objectPool.Count > 0)
    {
        GameObject objectToSpawn = objectPool.Dequeue();
        objectToSpawn.SetActive(true);
        objectToSpawn.transform.position = position;
        objectToSpawn.transform.rotation = rotation;
        IPooledObject pooledObj = objectToSpawn.GetComponent<IPooledObject>();
        if (pooledObj != null) pooledObj.OnObjectSpawn();
        return objectToSpawn;
    }
    else if (pools.Find(p => p.tag == tag).canGrow)
    {
        GameObject obj = Instantiate(pools.Find(p => p.tag == tag).prefab);
        obj.transform.position = position;
        obj.transform.rotation = rotation;
        return obj;
    }
    Debug.LogWarning(...);
    return null;
}

This SpawnFromPool() function is the heart of the pooling system. When I need to “spawn” a bullet (or any other pooled object), I call this function, providing the tag of the object type, the desired position, and rotation.

If the pool is empty (objectPool.Count <= 0), it means all objects in the pool are currently in use. Here, I check the canGrow flag for this pool type. If canGrow is true, I instantiate a new object directly (using Instantiate), set its position and rotation, and return it.  It’s important to note that this newly instantiated object is not automatically added back to the pool. I’ll need to make sure to return it later just like any other pooled object. If canGrow is false and the pool is empty, it logs a warning and returns null, indicating that no object could be spawned.

Returning Objects to Pool:

public void ReturnObjectToPool(string tag, GameObject objectToReturn)
{
    if (!poolDictionary.ContainsKey(tag)) { Debug.LogWarning(...); return; }
    objectToReturn.SetActive(false);
    poolDictionary[tag].Enqueue(objectToReturn);
}

The ReturnObjectToPool() function is used to put objects back into the pool when they are no longer needed. The function checks if the pool with the given tag exists. Then, it deactivates the objectToReturnusing objectToReturn.SetActive(false). This makes it invisible and stops its Update() and other MonoBehaviour functions, effectively “resetting” it for reuse. Finally, it enqueues the deactivated object back into the Queue using poolDictionary[tag].Enqueue(objectToReturn)Enqueue() adds the object to the back of the queue, making it available for the next time SpawnFromPool() is called.

Integrating Object Pooling Into Gameplay:

To make my bullet objects work with the pooling system, I created a Projectile script (attached to my bullet prefab):

public class Projectile : MonoBehaviour, IPooledObject
{
    public float speed = 10f;
    public float lifeTime = 2f;
    private float currentLifeTime;
    
    // IPooledObject interface method
    public void OnObjectSpawn()
    {
        currentLifeTime = lifeTime;
    }

    void Update()
    {
        transform.Translate(Vector3.forward * speed * Time.deltaTime);
        currentLifeTime -= Time.deltaTime;
        if (currentLifeTime <= 0)
        {
            ObjectPooler.Instance.ReturnObjectToPool("Bullet", gameObject);
        }
    }

    private void OnCollisionEnter(Collision collision)
    {
        ObjectPooler.Instance.ReturnObjectToPool("Bullet", gameObject);
    }
}

Notice that my Projectile script implements the IPooledObject interface. It’s a clean(er) way to handle object-specific setup when an object is taken from the pool. The IPooledObject interface simply defines a OnObjectSpawn() method. In my Projectilescript, I use OnObjectSpawn() to reset the currentLifeTime of the bullet every time it’s spawned from the pool, ensuring it always has its full lifespan.

In the Update() function of my Projectile script, I handle the bullet’s movement and lifetime. When the currentLifeTime reaches zero, I call ObjectPooler.Instance.ReturnObjectToPool("Bullet", gameObject) to return the bullet object to the “Bullet” pool. Similarly, in the OnCollisionEnter() function, when the bullet collides with something, I also return it to the pool. This ensures that bullets are always returned to the pool after they’ve served their purpose.

Of course, the final implementation is to spawn the bullets in the first place. In my player’s shooting script, the shooting logic becomes much cleaner and more efficient.

void Shoot()
{
    GameObject bullet = ObjectPooler.Instance.SpawnFromPool("Bullet", firePoint.position, firePoint.rotation);
    if (bullet == null)
    {
        Debug.LogWarning("Could not spawn bullet from pool.");
        return;
    }
    // No more Instantiate or Destroy here!  Performance win!
}

I just call ObjectPooler.Instance.SpawnFromPool("Bullet", firePoint.position, firePoint.rotation) to get a bullet from the pool and position it at the firePoint. There’s no longer a need to use or worry about using Instantiate and Destroy.

Conclusion

Implementing object pooling has made a noticeable difference in the performance of Ocean’s Chronicles, especially in levels where there are many projectiles and heavy game objects present. We significantly reduced the overhead of frequent instantiation and destruction, resulting in smoother frame rates and a more polished player experience. Beyond the performance benefits, using object pooling also leads to cleaner and more organised code, making the project easier to maintain and expand.