Work Examples

If my sharp wit and stunning good looks arent enough to impress, here’s a few examples of work I’ve done. Most if not all of my professional paid work is NDA’d, but as I’m always working on personal projects and staying sharp, I have a few examples of work to write up/show off!

Project Feral

Project Feral is the newest project we’re tinkering with at Bokka Co.

It’s an online co-op first-person heisting roguelike where you and your friends play as criminal cats heisting whatever they can get their paws on, fending off waves of K9 units. More information is available on request, and should be on projects soon enough, but something I’d like to show off is this wonderful wave-spawning system I’ve got.

The game, similar to titles like Left 4 Dead or the Payday series, features seemingly endless waves of enemies attempting to stop the feline filchers from freeing fools of their freight. This necessitates a system that can:

  • Scale based on wave count, player count, difficulty, and countless other factors.
  • React to events, and inform other systems of its going ons.
  • Create a balanced experience, keeping a steady stream of enemies flowing.
  • Allow for variance for players to take advantage of, or struggle with.

With those in mind, I’ve prototyped an extensible, performant, and very readable spawning manager from home-grown organic code. Do bear in mind the project is in very early development, and my aim with this was to make a system that implements everything we will need, while allowing easy expansion and flexibility as the game takes shape.

The full script is below, but I’ll try summarise the general loop and some of the reasons behind how it’s made. If you just want a video of it working, scroll to the bottom of the page and check it out!

using System;
using System.Collections.Generic;
using System.Linq;
using Feral.Utility;
using UnityEngine;
using UnityEngine.Serialization;
using Random = UnityEngine.Random;

namespace Feral
{
	public class SpawningManager : MonoBehaviour
	{
		public enum Difficulty { Easy, Normal, Hard, Expert }
		
		public struct SpawnEvent
		{
			public float spawnPercentage; // 0-1, Percentage of wave complete that the event triggers
			public Dictionary<SO_EnemySpawnData, int> enemies;
			public EnemySpawnPoint spawnPoint;
		}
		
		//DEBUG
		[SerializeField] private SO_HeistDefinition debugHeistDefinition;
		[SerializeField] private Difficulty debugDifficulty;

		//Event Channels
		[SerializeField] private SO_IntEventChannel updateThreatEvent;
		[SerializeField] private SO_IntEventChannel waveStartEvent;
		
		//Map Elements
		[SerializeField] private List<EnemySpawnPoint> spawnPoints;

		//Heist Progress
		[SerializeField] private int currentWave = 0;
		[SerializeField] private float waveProgressPercentage = 0f;
		[SerializeField] private float percentageUntilNextSpawn;
		[SerializeField] private float waveTimescale = 1f;
		[SerializeField] private float waveLength = 0;
		[SerializeField] private float timeOnWave = 0;
		private float lastSpawnPercentage = 0;
		
		//Spawning
		[SerializeField] private int threatScale = 0;
		[SerializeField] private int currentThreatScale = 0;
		[SerializeField] SO_HeistDefinition.EnemyDefinition[] allowedEnemies;
		private Dictionary<SO_EnemySpawnData, float> enemySpawnWeightTotals = new Dictionary<SO_EnemySpawnData, float>();
		private List<SpawnEvent> spawnEvents;

		private void OnEnable()
		{
			updateThreatEvent.OnEventTrigger += UpdateCurrentThreat;
		}

		private void OnDisable()
		{
			updateThreatEvent.OnEventTrigger -= UpdateCurrentThreat;
		}
		
		public void RegisterSpawnPoint(EnemySpawnPoint point)
		{
			if (!spawnPoints.Contains(point)) spawnPoints.Add(point);
		}

		public void DeregisterSpawnPoint(EnemySpawnPoint point)
		{
			spawnPoints.Remove(point);
		}

		private void Awake()
		{
			//Grab starting values from Heist Definition, then generate the first wave.
			allowedEnemies = debugHeistDefinition.allowedEnemies;
			threatScale = debugHeistDefinition.startingThreat;
			waveLength = debugHeistDefinition.waveLength;
			GenerateWave();
		}

		private void GenerateWave()
		{
			//Increment the current wave, resetting the wave timer
			currentWave++;
			timeOnWave = 0f;
			lastSpawnPercentage = 0;
			//Recalculate the threat target and wave length
			float difficultyScalar = GetDifficultyScalar(debugDifficulty);
			threatScale = Mathf.FloorToInt(debugHeistDefinition.startingThreat + 
			                               (debugHeistDefinition.perWaveThreatScalar * (currentWave - 1)) * difficultyScalar);
			waveLength = debugHeistDefinition.waveLength +
			              (debugHeistDefinition.perWaveLengthScalar * (currentWave - 1)) * difficultyScalar;
			//Apply any wave-specific modifications
			ApplyWaveModifications();
			//Generate the spawn events and distribute them across the wave.
			spawnEvents = GenerateSpawnEvents(CalculateEnemiesToSpawn());
			//Update the custom editor string display
			SetReadableNextSpawn();
			//Send event trigger to any listeners through a scriptable object event channel
			waveStartEvent.TriggerEvent(currentWave);
		}

		private void Update()
		{
			timeOnWave += Time.deltaTime * waveTimescale;
			waveProgressPercentage = timeOnWave / waveLength;
			if (timeOnWave >= waveLength)
			{
				GenerateWave();
				return;
			}
			
			if (spawnEvents == null || spawnEvents.Count == 0) return;
			percentageUntilNextSpawn = (waveProgressPercentage-lastSpawnPercentage) / (spawnEvents[0].spawnPercentage-lastSpawnPercentage);
			if (spawnEvents[0].spawnPercentage <= waveProgressPercentage)
			{
				spawnEvents[0].spawnPoint.SpawnEnemies(spawnEvents[0].enemies);
				lastSpawnPercentage = spawnEvents[0].spawnPercentage;
				spawnEvents.RemoveAt(0);
				SetReadableNextSpawn();
			}
		}

		private bool ApplyWaveModifications()
		{
			if (debugHeistDefinition.waveModificationsDict.TryGetValue(currentWave, out var waveModification))
			{
				threatScale += waveModification.threatScalar;
				waveLength += waveModification.lengthScalar;
				allowedEnemies = waveModification.allowedEnemies;
				return true;
			}
			else
			{
				return false;
			}
		}

		private float GetDifficultyScalar(Difficulty difficulty) => difficulty switch
		{
			Difficulty.Easy => 0.75f,
			Difficulty.Normal => 1f,
			Difficulty.Hard => 1.25f,
			Difficulty.Expert => 1.5f,
			_ => throw new ArgumentOutOfRangeException(nameof(difficulty), difficulty, null)
		};

		private Dictionary<SO_EnemySpawnData, int> CalculateEnemiesToSpawn()
		{
			if (allowedEnemies == null || allowedEnemies.Length == 0)
			{
				Debug.LogError("No enemies in allowed enemies for heist");
				return new Dictionary<SO_EnemySpawnData, int>();
			}
			Dictionary<SO_EnemySpawnData, int> enemiesToSpawn = new Dictionary<SO_EnemySpawnData, int>();
			//Loop through all allowed enemies adding weight to totals, adding/incrementing the highest each time to dict and subtracting the threat from the desired total.
			int remainingThreatToSpawn = threatScale;
			while (remainingThreatToSpawn > 0)
			{
				SO_EnemySpawnData highestSpawnTotal = null;
				foreach (var enemyDefinition in allowedEnemies)
				{
					SO_EnemySpawnData enemyData = enemyDefinition.enemySpawnData;
					enemySpawnWeightTotals.TryAdd(enemyData, 0f);
					if (enemySpawnWeightTotals.TryGetValue(enemyData, out var currentSpawnWeightTotal))
					{
						enemySpawnWeightTotals[enemyData] = currentSpawnWeightTotal + enemyDefinition.weight;
						if (highestSpawnTotal == null || enemySpawnWeightTotals[enemyData] > enemySpawnWeightTotals[highestSpawnTotal])
						{
							highestSpawnTotal = enemyData;
						}
					}
				}
				enemiesToSpawn.TryAdd(highestSpawnTotal, 0);
				enemiesToSpawn[highestSpawnTotal] += 1;
				enemySpawnWeightTotals[highestSpawnTotal] = 0f;
				remainingThreatToSpawn -= highestSpawnTotal.threat;
			}
			//Once the desired threat quota is met, return the populated dict.
			return enemiesToSpawn;
		}

		private List<SpawnEvent> GenerateSpawnEvents(Dictionary<SO_EnemySpawnData, int> enemiesToSpawn)
		{
			List<SpawnEvent> events = new List<SpawnEvent>();
			//Calculate how many spawn events, and the average total threat a spawn event should have
			int eventsToGenerate = Math.Clamp((threatScale / debugHeistDefinition.maxThreatPerSpawnEvent),5, (int)waveLength);
			int totalWaveThreat = 0;
			foreach (var enemy in enemiesToSpawn)
			{
				totalWaveThreat += enemy.Key.threat * enemy.Value;
			}
			int eventThreatThreshold = (totalWaveThreat + 1) / eventsToGenerate;
			int generatedEvents = 0;
			while (generatedEvents < eventsToGenerate)
			{
				//Generate a spawn event with a random spawnpoint
				SpawnEvent newSpawnEvent = new SpawnEvent();
				newSpawnEvent.spawnPoint = spawnPoints[Random.Range(0, spawnPoints.Count)];
				Dictionary<SO_EnemySpawnData, int> eventEnemies = new Dictionary<SO_EnemySpawnData, int>();
				int currentEventThreat = 0;
				List<SO_EnemySpawnData> availableEnemies = enemiesToSpawn.Keys.ToList();
				while (currentEventThreat < eventThreatThreshold && enemiesToSpawn.Count > 0)
				{
					var enemyToSpawn = availableEnemies[Random.Range(0, availableEnemies.Count)];
					if (!eventEnemies.TryAdd(enemyToSpawn, 1))
					{
						eventEnemies[enemyToSpawn]++;
					}

					currentEventThreat += enemyToSpawn.threat;
					enemiesToSpawn[enemyToSpawn]--;
					if (enemiesToSpawn[enemyToSpawn] <= 0)
					{
						enemiesToSpawn.Remove(enemyToSpawn);
						availableEnemies.Remove(enemyToSpawn);
					}
				}

				newSpawnEvent.spawnPercentage = 1 - ((generatedEvents + 1f)/(eventsToGenerate + 1f));
				newSpawnEvent.enemies = eventEnemies;
				events.Add(newSpawnEvent);
				Debug.Log($"Adding Spawn Event to wave {currentWave} with {currentEventThreat} threat, at {newSpawnEvent.spawnPoint.name}");
				generatedEvents++;
			}
			//Reverse the list to make earlier weighted spawns first, then return
			events.Reverse();
			return events;
		}

		private void UpdateCurrentThreat(int change)
		{
			currentThreatScale += change;
		}

		[SerializeField] private string nextSpawnEnemies = "";
		[SerializeField] private string nextSpawnPoint = "";
		[SerializeField] private string nextSpawnPercentage = "";

		private void SetReadableNextSpawn()
		{
			if (spawnEvents != null && spawnEvents.Count > 0)
			{
				var enemyStrings = spawnEvents[0].enemies.Select(e => $"{e.Value} x {e.Key.enemy.name}");
				string enemyString = string.Join("\n", enemyStrings);
				nextSpawnEnemies = enemyString;
				nextSpawnPoint = spawnEvents[0].spawnPoint.gameObject.name;
				nextSpawnPercentage = spawnEvents[0].spawnPercentage.ToString("f");
			}
			else
			{
				nextSpawnEnemies = "";
				nextSpawnPoint = "No spawns remaining this wave";
				nextSpawnPercentage = "";
			}
		}
	}

}

Architecture

The system utilises Unity’s ScriptableObjects system heavily, making it extremely designer friendly and quick to iterate gameplay with.

Almost all of it’s paramaters are pulled from a scriptable object (SO_HeistDefinition) rather than hardcoding, meaning multiple variable setups can be tested quickly and tweaked easily.

using System;
using System.Collections.Generic;
using Feral.Enemies;
using UnityEngine;

namespace Feral
{
    [CreateAssetMenu(fileName = "SO_HeistDefinition", menuName = "Scriptable Objects/SO_HeistDefinition")]
    public class SO_HeistDefinition : ScriptableObject
    {
        [Serializable]
        public struct EnemyDefinition
        {
            public SO_EnemySpawnData enemySpawnData;
            public float weight;
        }
        [Serializable]
        public struct WaveModification
        {
            public int waveNumber;
            public int threatScalar;
            public float lengthScalar;
            public EnemyDefinition[] allowedEnemies;
        }
        public EnemyDefinition[] allowedEnemies;
        public int maxThreatPerSpawnEvent = 10;
        public int startingThreat;
        public float perWaveThreatScalar;
        public float waveLength;
        public float perWaveLengthScalar;
        public WaveModification[] waveModifications;
        public Dictionary<int,WaveModification> waveModificationsDict;
        
        #if UNITY_EDITOR
        private void OnValidate()
        {
            waveModificationsDict = new Dictionary<int,WaveModification>();
            if (waveModifications == null || waveModifications.Length == 0) return;
            foreach (WaveModification waveModification in waveModifications)
            {
                if (!waveModificationsDict.TryAdd(waveModification.waveNumber, waveModification))
                {
                    Debug.LogError($"Duplicate Wave Modifications for Wave {waveModification.waveNumber} in {this.name}");
                }
            }
        }
        #endif
    }
}

The heist definition contains the initial values for the heist, such as what enemies can spawn in the heist and how often, the starting ‘threat’ value (explained later), the length of a wave, and how those values scale as the waves go on.

public EnemyDefinition[] allowedEnemies;
public int maxThreatPerSpawnEvent = 10;
public int startingThreat;
public float perWaveThreatScalar;
public float waveLength;
public float perWaveLengthScalar;

It also contains a dictionary of wave modifications.

public struct WaveModification
{
    public int waveNumber;
    public int threatScalar;
    public float lengthScalar;
    public EnemyDefinition[] allowedEnemies;
}
public WaveModification[] waveModifications;
public Dictionary<int,WaveModification> waveModificationsDict;

Wave modifications allow designers to specify behaviour outside the normal scaling for a specific wave. By adding entries to the scriptable object asset in the inspector they can modify the threat, wave length, and allowed enemies. For example, you could have a boss wave where only tank enemies are allowed to spawn, or a quiet wave offering respite from a prolonged assault.

I use a seperate array and dictionary to navigate Unity’s serialisation, providing an easy to edit format for designers, while maintaining O(1) lookup speeds at runtime.

Plans to extend this include pre-set logic (likely accompanied by an enum) for ‘quiet’ waves, or full on assaults.

Setup

The script starts by listening to one of my scriptable object event channels.

private void OnEnable()
{
	updateThreatEvent.OnEventTrigger += UpdateCurrentThreat;
}

private void OnDisable()
{
	updateThreatEvent.OnEventTrigger -= UpdateCurrentThreat;
}

This is an architecture I’ve been experimenting with more and more, it allows for systems to operate in near complete isolation. Rather than tightly coupling scripts, or even loosely coupling using events, I’m able to store the action in a scriptable object asset, allowing for any script that cares about broadcasting or listening to simply hold that asset, and call it/listen to it (including sending/recieving parameters) without knowing anything about each other at all.

[CreateAssetMenu(fileName = "IntEventChannel", menuName = "Events/Int Event Channel")]
public class SO_IntEventChannel : ScriptableObject
{
    public UnityAction<int> OnEventTrigger;

    public void TriggerEvent(int value)
    {
        if (OnEventTrigger != null)
        {
            OnEventTrigger.Invoke(value);
        }
    }
}

Then, using an observer pattern, spawn points register themselves with the manager, removing the need to add spawn points in the inspector, and allowing for dynamically adding and removing them at runtime. The full spawn point script is attached below.

using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using Random = UnityEngine.Random;

namespace Feral
{
    public class EnemySpawnPoint : MonoBehaviour
    {
        private SpawningManager spawningManager;

        public float spawnRadius = 4;
        public bool vehicleSpawn = false;
        
        //events
        public SO_IntEventChannel threatChangeEvent;

        private void OnValidate()
        {
            if (spawningManager == null)
            {
                spawningManager = FindAnyObjectByType<SpawningManager>();
            }
        }

        private void OnEnable()
        {
            spawningManager.RegisterSpawnPoint(this);
        }

        private void OnDisable()
        {
            spawningManager.DeregisterSpawnPoint(this);
        }

        public void SpawnEnemies(Dictionary<SO_EnemySpawnData, int> enemiesToSpawn)
        {
            if (enemiesToSpawn.Count == 0)
            {
                Debug.Log("No enemies to spawn");
                return;
            }
            foreach (var enemy in enemiesToSpawn)
            {
                //Debug.Log("Spawning: " + enemy.Value + " " + enemy.Key.name + "(s)");
                var spawnPos = (Random.insideUnitSphere * spawnRadius) + transform.position;
                spawnPos.y = transform.position.y;
                var spawnedEnemy = Instantiate(enemy.Key.enemy, spawnPos, Quaternion.identity);
                spawnedEnemy.GetComponent<NetworkObject>().Spawn();
                threatChangeEvent.OnEventTrigger(enemy.Key.threat * enemy.Value);
            }
        }

        private readonly Color gizmosColour = new Color(1f, 0.1f, 0.1f, 0.3f);

        void OnDrawGizmos()
        {
            Gizmos.color = gizmosColour;
            Gizmos.DrawSphere(transform.position, spawnRadius);
        }
    }
}
public void RegisterSpawnPoint(EnemySpawnPoint point)
{
	if (!spawnPoints.Contains(point)) spawnPoints.Add(point);
}

public void DeregisterSpawnPoint(EnemySpawnPoint point)
{
	spawnPoints.Remove(point);
}

On awake, the script grabs the starting values from the heist definition, then generates the first wave

private void Awake()
{
	//Grab starting values from Heist Definition, then generate the first wave.
	allowedEnemies = debugHeistDefinition.allowedEnemies;
	threatScale = debugHeistDefinition.startingThreat;
	waveLength = debugHeistDefinition.waveLength;
	GenerateWave();
}

The generate wave function (not that kind of wave function) increments the wave count, resets timers, and scales values based on the difficulty.

private void GenerateWave()
{
	//Increment the current wave, resetting the wave timer
	currentWave++;
	timeOnWave = 0f;
	lastSpawnPercentage = 0;
	//Recalculate the threat target and wave length
	float difficultyScalar = GetDifficultyScalar(debugDifficulty);
	threatScale = Mathf.FloorToInt(debugHeistDefinition.startingThreat + 
	                               (debugHeistDefinition.perWaveThreatScalar * (currentWave - 1)) * difficultyScalar);
	waveLength = debugHeistDefinition.waveLength +
	              (debugHeistDefinition.perWaveLengthScalar * (currentWave - 1)) * difficultyScalar;
	//Apply any wave-specific modifications
	ApplyWaveModifications();
	//Generate the spawn events and distribute them across the wave.
	spawnEvents = GenerateSpawnEvents(CalculateEnemiesToSpawn());
	//Update the custom editor string display
	SetReadableNextSpawn();
	//Send event trigger to any listeners through a scriptable object event channel
	waveStartEvent.TriggerEvent(currentWave);
}

It then applies wave modifications from the heist definition’s dictionary, before getting into the bulk of the functionality, the enemy and spawn event generation.

Enemy/Event Generation

The process starts with generating all the enemies that will spawn on the wave, with this function.

private Dictionary<SO_EnemySpawnData, int> CalculateEnemiesToSpawn()
{
	if (allowedEnemies == null || allowedEnemies.Length == 0)
	{
		Debug.LogError("No enemies in allowed enemies for heist");
		return new Dictionary<SO_EnemySpawnData, int>();
	}
	Dictionary<SO_EnemySpawnData, int> enemiesToSpawn = new Dictionary<SO_EnemySpawnData, int>();
	//Loop through all allowed enemies adding weight to totals, adding/incrementing the highest each time to dict and subtracting the threat from the desired total.
	int remainingThreatToSpawn = threatScale;
	while (remainingThreatToSpawn > 0)
	{
		SO_EnemySpawnData highestSpawnTotal = null;
		foreach (var enemyDefinition in allowedEnemies)
		{
			SO_EnemySpawnData enemyData = enemyDefinition.enemySpawnData;
			enemySpawnWeightTotals.TryAdd(enemyData, 0f);
			if (enemySpawnWeightTotals.TryGetValue(enemyData, out var currentSpawnWeightTotal))
			{
				enemySpawnWeightTotals[enemyData] = currentSpawnWeightTotal + enemyDefinition.weight;
				if (highestSpawnTotal == null || enemySpawnWeightTotals[enemyData] > enemySpawnWeightTotals[highestSpawnTotal])
				{
					highestSpawnTotal = enemyData;
				}
			}
		}
		enemiesToSpawn.TryAdd(highestSpawnTotal, 0);
		enemiesToSpawn[highestSpawnTotal] += 1;
		enemySpawnWeightTotals[highestSpawnTotal] = 0f;
		remainingThreatToSpawn -= highestSpawnTotal.threat;
	}
	//Once the desired threat quota is met, return the populated dict.
	return enemiesToSpawn;
}
public class SO_EnemySpawnData : ScriptableObject
{
   public GameObject enemy;
   public int threat = 1;
}

The function recursively adds the ‘weight’ value stored in an ‘EnemyDefinition’ struct on the heist definition to running totals for each valid spawn. It then takes the highest weight option, and adds it to the output dictionary.

This system allows for relative portioning of spawns without needing some magic number total for all the spawn chances to add up to, reducing the chance of user error (or float inaccuracy) messing up odds or the logic. It also means you avoid random chance giving a stupid hard or boringly easy spawn roll you can sometimes get with a “blindbag” or similar system. Instead you get consistent, even spawning that matches up with designer intent, with some flexibility baked in as the final total ‘threat’ doesn’t need to exactly match the intended threat for the wave.

The enemies to spawn are then passed into “GenerateSpawnEvents”.

private List<SpawnEvent> GenerateSpawnEvents(Dictionary<SO_EnemySpawnData, int> enemiesToSpawn)
		{
			List<SpawnEvent> events = new List<SpawnEvent>();
			//Calculate how many spawn events, and the average total threat a spawn event should have
			int eventsToGenerate = Math.Clamp((threatScale / debugHeistDefinition.maxThreatPerSpawnEvent),5, (int)waveLength);
			int totalWaveThreat = 0;
			foreach (var enemy in enemiesToSpawn)
			{
				totalWaveThreat += enemy.Key.threat * enemy.Value;
			}
			int eventThreatThreshold = (totalWaveThreat + 1) / eventsToGenerate;
			int generatedEvents = 0;
			while (generatedEvents < eventsToGenerate)
			{
				//Generate a spawn event with a random spawnpoint
				SpawnEvent newSpawnEvent = new SpawnEvent();
				newSpawnEvent.spawnPoint = spawnPoints[Random.Range(0, spawnPoints.Count)];
				Dictionary<SO_EnemySpawnData, int> eventEnemies = new Dictionary<SO_EnemySpawnData, int>();
				int currentEventThreat = 0;
				List<SO_EnemySpawnData> availableEnemies = enemiesToSpawn.Keys.ToList();
				while (currentEventThreat < eventThreatThreshold && enemiesToSpawn.Count > 0)
				{
					var enemyToSpawn = availableEnemies[Random.Range(0, availableEnemies.Count)];
					if (!eventEnemies.TryAdd(enemyToSpawn, 1))
					{
						eventEnemies[enemyToSpawn]++;
					}

					currentEventThreat += enemyToSpawn.threat;
					enemiesToSpawn[enemyToSpawn]--;
					if (enemiesToSpawn[enemyToSpawn] <= 0)
					{
						enemiesToSpawn.Remove(enemyToSpawn);
						availableEnemies.Remove(enemyToSpawn);
					}
				}

				newSpawnEvent.spawnPercentage = 1 - ((generatedEvents + 1f)/(eventsToGenerate + 1f));
				newSpawnEvent.enemies = eventEnemies;
				events.Add(newSpawnEvent);
				Debug.Log($"Adding Spawn Event to wave {currentWave} with {currentEventThreat} threat, at {newSpawnEvent.spawnPoint.name}");
				generatedEvents++;
			}
			//Reverse the list to make earlier weighted spawns first, then return
			events.Reverse();
			return events;
		}

This function starts by dynamically calculating how many ‘spawn events’ should occur during the wave and how the total threat value should be spread to ensure a steady flow of enemies without having them walking out of spawns single file.

It then picks randomly from the original, balanced output of enemies from the first function, adding them to a spawn event until it hits the desired threat value, moving on to generate another when it does. Once all events are generated it returns the list of spawn events ready to be used in the wave.

The last part of “GenerateWave” features a little sneak peek into something I haven’t mentioned yet, and an example of sending an event notification through the scriptable object event channel system mentioned earlier.

//Update the custom editor string display
SetReadableNextSpawn();
//Send event trigger to any listeners through a scriptable object event channel
waveStartEvent.TriggerEvent(currentWave);

The Update loop

private void Update()
{
	timeOnWave += Time.deltaTime * waveTimescale;
	waveProgressPercentage = timeOnWave / waveLength;
	if (timeOnWave >= waveLength)
	{
		GenerateWave();
		return;
	}
	
	if (spawnEvents == null || spawnEvents.Count == 0) return;
	percentageUntilNextSpawn = (waveProgressPercentage-lastSpawnPercentage) / (spawnEvents[0].spawnPercentage-lastSpawnPercentage);
	if (spawnEvents[0].spawnPercentage <= waveProgressPercentage)
	{
		spawnEvents[0].spawnPoint.SpawnEnemies(spawnEvents[0].enemies);
		lastSpawnPercentage = spawnEvents[0].spawnPercentage;
		spawnEvents.RemoveAt(0);
		SetReadableNextSpawn();
	}
}

The Update loop increments the variable tracking how long the wave has gone on for provides a few more hints as to the special surprise, and checks if the next spawn event to occur is due. If it is, it sends the enemies to spawn to a spawn point to instantiate and spawn them on clients.

using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using Random = UnityEngine.Random;

namespace Feral
{
    public class EnemySpawnPoint : MonoBehaviour
    {
        private SpawningManager spawningManager;

        public float spawnRadius = 4;
        public bool vehicleSpawn = false;
        
        //events
        public SO_IntEventChannel threatChangeEvent;

        private void OnValidate()
        {
            if (spawningManager == null)
            {
                spawningManager = FindAnyObjectByType<SpawningManager>();
            }
        }

        private void OnEnable()
        {
            spawningManager.RegisterSpawnPoint(this);
        }

        private void OnDisable()
        {
            spawningManager.DeregisterSpawnPoint(this);
        }

        public void SpawnEnemies(Dictionary<SO_EnemySpawnData, int> enemiesToSpawn)
        {
            if (enemiesToSpawn.Count == 0)
            {
                Debug.Log("No enemies to spawn");
                return;
            }
            foreach (var enemy in enemiesToSpawn)
            {
                //Debug.Log("Spawning: " + enemy.Value + " " + enemy.Key.name + "(s)");
                var spawnPos = (Random.insideUnitSphere * spawnRadius) + transform.position;
                spawnPos.y = transform.position.y;
                var spawnedEnemy = Instantiate(enemy.Key.enemy, spawnPos, Quaternion.identity);
                spawnedEnemy.GetComponent<NetworkObject>().Spawn();
                threatChangeEvent.OnEventTrigger(enemy.Key.threat * enemy.Value);
            }
        }

        private readonly Color gizmosColour = new Color(1f, 0.1f, 0.1f, 0.3f);

        void OnDrawGizmos()
        {
            Gizmos.color = gizmosColour;
            Gizmos.DrawSphere(transform.position, spawnRadius);
        }
    }
}

If the time elapsed overtakes the wave length, a new wave is generated, incrementing values by their scalar and starting the loop anew.

The Bonus Round

This code is nice, it’s got all the lovely things you want like proper use of dictionaries and pretty C# switch expressions, but one of my favourite parts of making this system was getting to grips with some editor tooling!

As I developed this system I found there was lots of nice ease-of-access features like the asset-driven heist definitions or the easy to use event channels. But the inspector was getting a bit garish, so I made a custom editor using Unity’s UI Toolkit.

This is made using a custom editor for “SpawningManager” and unity’s answer to HTML and CSS, UXML and USS. I created the UI largely through the handy UI Builder tool, with some code hooks, human readable string generation in the manager, and a bunch of fiddling with aligning elements nicely I managed to create a pretty nifty realtime updating inspector editor for the script. Allowing me to see, on the fly, how far through the wave I am, what event is going to spawn next, and many other useful bits for tweaking values and debugging behaviours.

using System;
using UnityEngine;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine.UIElements;

namespace Feral
{
    [CustomEditor(typeof(SpawningManager))]
    public class SpawningManagerEditor : Editor
    {
        public VisualTreeAsset visualTree;

        private SpawningManager spawningManager;
        private Toggle debugToggle;
        private VisualElement debugVars;

        private void OnEnable()
        {
            visualTree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/UXML/SpawningManagerVisualTree.uxml");
            spawningManager = (SpawningManager)target;
        }

        public override VisualElement CreateInspectorGUI()
        {
            VisualElement root = new VisualElement();
            visualTree.CloneTree(root);
            debugToggle = root.Q<Toggle>("DebugToggle");
            debugVars = root.Q<VisualElement>("DebugVars");
            debugToggle.RegisterCallback<ChangeEvent<bool>>(OnDebugToggled);
            return root;
        }

        private void OnDebugToggled(ChangeEvent<bool> evt)
        {
            if (evt.newValue)
            {
                debugVars.style.display = DisplayStyle.Flex;
            }
            else
            {
                debugVars.style.display = DisplayStyle.None;
            }
        }
    }
}

[SerializeField] private string nextSpawnEnemies = "";
[SerializeField] private string nextSpawnPoint = "";
[SerializeField] private string nextSpawnPercentage = "";

private void SetReadableNextSpawn()
{
	if (spawnEvents != null && spawnEvents.Count > 0)
	{
		var enemyStrings = spawnEvents[0].enemies.Select(e => $"{e.Value} x {e.Key.enemy.name}");
		string enemyString = string.Join("\n", enemyStrings);
		nextSpawnEnemies = enemyString;
		nextSpawnPoint = spawnEvents[0].spawnPoint.gameObject.name;
		nextSpawnPercentage = spawnEvents[0].spawnPercentage.ToString("f");
	}
	else
	{
		nextSpawnEnemies = "";
		nextSpawnPoint = "No spawns remaining this wave";
		nextSpawnPercentage = "";
	}
}

The system in action

As mentioned prior, the system exists in a very early state of the game as it’s intended to enable development and testing in the early stages, expanding even more in functionality as and when needed.With that said, here’s a video demonstrating it working accompanied by some early music put together by Bokka Co’s own George Walker.

Future Plans

I’ve already got a few bits of functionality in the system ready for future expansion. Here’s a few examples of what I’m likely to be adding.

  • ‘Rubberbanding’ – I currently keep track of the threat on the map and have the ability to alter how quickly the wave progresses. I’m considering adding a rubberbanding mechanic where if players are struggling to deal with enemies the wave can slow down to allow more time between spawns, inversely if they end up with a broken build and blast through enemies I can skip forwards to give them more of an appropriate challenge.
  • Dynamic spawn points – With the observer pattern I’m using for spawn points I can quite easily add more or take some away at will. This could be used like in Left 4 Dead where spawn points are more active/plentiful where there are more players, or in games like Deep Rock Galactic where there’s no reason to spawn enemies in future areas of a level if the players haven’t reached it yet.
  • Reactive wave generation – The system is set up to both send and recieve events through the scriptable object channels and so I would like to look into how I can have spawns react in a meaningful way to player actions mid-heist. This could mean causing a lighter ‘retreat’ wave if they scramble radio signals, or a more intense assault as they breach a vault.
  • Roguelike wave modification – As the game is intended to primarily be played as a run-based roguelike where heists are completed one after another while abilities/upgrades are picked inbetween heists, I’d like to do some investigating into how I can implement that into the spawning. For example, a player may take ‘fewer enemies spawn but they are generally stronger’, and I’d need to be able to change the weights of spawned enemies, or give enemies upgrades as they spawn.

First Class Rescue

First Class Rescue is the main project we at Bokka Co are working on. I am the sole designer on the project, and was tech lead for most of development. One of the main contributions I made was the player character controller, and building controller. This controller isn’t perfect as it’s somewhat the product of having to adapt existing code around a rapidly changing game, but it served it’s purpose for the prototype amazingly, getting us to where we needed to be under a deadline. We’re now in the process of refactoring large portions of the game, the character controller included.

using System;
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using System.Linq;

public class PlayerCharacterController : MonoBehaviour
{
    enum PlayerState { Walking, Climbing, Jumping, Falling}
    public static PlayerCharacterController Instance { get; private set; }

    [Header("Scripts")]
    public BuildingController buildingController;

    [Header("Components")]
    public PlayerInputController inputController;
    [SerializeField] private Rigidbody2D rb;
    [SerializeField] private Collider2D playerCollider;
    [SerializeField] private Collider2D feetCollider;
    [SerializeField] private FloorChecker[] groundCheckers; // 0: left, 1: center, 2: right
    [SerializeField] private Animator animator;
    [SerializeField] private TMP_Text interactPrompt;

    [Header("World Objects")]
    [SerializeField] private GameObject currentClimbable;
    [SerializeField] private GameObject currentGroundedPlatform;
    [SerializeField] private GameObject climbingAnchor;
    [SerializeField] private IInteractable validInteraction;
    private List<GameObject> ignoredPlatforms = new List<GameObject>();

    [Header("Movement Vars")]
    [Header("Walking")]
    [SerializeField] private float moveSpeed = 6f;
    [SerializeField] private float accelerationRate = 0.2f;
    [SerializeField] private float deccelerationRate = 0.2f;
    [SerializeField] private float walkHelpAngle = 15f;
    [SerializeField] private float maxWalkAngle = 55f;
    [SerializeField] private float currentPlatformAngle = 0f;
    [SerializeField] private float platformAngleSmoothSpeed = 360f; // degrees per second
    private float targetPlatformAngle = 0f;
    [Header("Jumping/Air Control")]
    [SerializeField] private float jumpForce = 8f;
    [SerializeField] private float downForce = 3f;
    [SerializeField] private float timeBeforeDownforce = 1f;
    [SerializeField] private float coyoteTime = 0.1f;
    [SerializeField] private float jumpBuffer = 0.15f;
    [SerializeField] private float jumpInputTime = 0f;
    [SerializeField] private float maxJumpMovementCooldown = 0.2f;
    [SerializeField] private float maxAirSpeed = 6f;
    [SerializeField] private float airControlPower = 3f;
    [SerializeField] private float timeInAir = 0f;
    [Header("Climbing")]
    [SerializeField] private float climbSpeed = 2f;
    [SerializeField] private float maxStamina = 3f;
    [SerializeField] private float currentStamina;
    [SerializeField] private float restingDrainRate = 0.5f;
    [SerializeField] private Vector2 currentClimbVelocity = Vector2.zero;
    [SerializeField] private Vector2 previousPosition = Vector2.zero;

    [Header("State")]
    [SerializeField] private PlayerState currentState = PlayerState.Walking;
    public bool isBuilding = false;
    public bool isDisabled = false;

    [Header("Animation")]
    [SerializeField] private Animator playerAnimator;
    [SerializeField] private static readonly string RUN_STATE = "Landing";
    [SerializeField] private static readonly string JUMP_STATE = "Jumping";
    [SerializeField] private static readonly string CLIMB_STATE = "Climb";
    [SerializeField] private static readonly string BUILD_STATE = "Building";

    [Header("Audio")]
    [SerializeField] private FMODUnity.EventReference landingSound;

    private void Awake()
    {
        targetPlatformAngle = currentPlatformAngle;
    }

    private void OnEnable()
    {
        if (Instance == null)
        {
            Instance = this;
        }
        else
        {
            Destroy(gameObject);
            return;
        }
        currentStamina = maxStamina;
    }

    private void Update()
    {
        if (isDisabled) return;
        CheckGroundedWithRaycast();
        UpdateAnimVars();
        if (currentState == PlayerState.Climbing)
        {
            currentClimbVelocity = CalculateVelocity();
            ClimbingMovement();
        }
    }

    private void FixedUpdate()
    {
        // Smooth the current platform angle toward the target angle to avoid sudden tangent flips.
        currentPlatformAngle = Mathf.MoveTowardsAngle(currentPlatformAngle, targetPlatformAngle, platformAngleSmoothSpeed * Time.fixedDeltaTime);
        if (Mathf.Abs(inputController.moveInput.x) < 0.01)
        {
            rb.linearVelocity = new Vector2(Mathf.Lerp(rb.linearVelocityX, 0, accelerationRate), rb.linearVelocityY);
        }
        switch (currentState)
        {
            case PlayerState.Walking:
                Walking();
                break;
            case PlayerState.Jumping:
                AirControl();
                break;
            case PlayerState.Falling:
                AirControl();
                break;
            case PlayerState.Climbing:
                //ClimbingMovement();
                break;
        }
        TimeEffects();
    }

    private void UpdatePlayerState(PlayerState state)
    {
        currentState = state;
        //Debug.Log($"Player state updated to: {state}");
        switch (state)
        {
            case PlayerState.Walking:
                FMODUnity.RuntimeManager.PlayOneShot(landingSound, transform.position);
                animator.Play(RUN_STATE);
                break;
            case PlayerState.Jumping:
                animator.Play(JUMP_STATE);
                break;
            case PlayerState.Falling:
                animator.Play(JUMP_STATE);
                break;
            case PlayerState.Climbing:
                animator.Play(CLIMB_STATE);
                break;
        }
    }

    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.TryGetComponent<IInteractable>(out IInteractable interactable))
        {
            validInteraction = interactable;
            interactPrompt.gameObject.SetActive(true);
        }
    }
    private void OnTriggerExit2D(Collider2D collision)
    {
        if (collision.TryGetComponent<IInteractable>(out IInteractable interactable) && interactable == validInteraction)
        {
            validInteraction = null;
            interactPrompt.gameObject.SetActive(false);
        }
    }

    public void DisableCharacter()
    {
        if (currentState == PlayerState.Climbing)
        {
            StopClimbing(false);
        }
        rb.simulated = false;
        isDisabled = true;
    }
    public void EnableCharacter(bool resetVelocity = false)
    {
        rb.simulated = true;
        if (resetVelocity)
        {
            rb.linearVelocity = Vector2.zero;
        }
        PlayerInputController.SwitchActionMap(PlayerInputController.ActionMap.Platforming);
        isDisabled = false;
    }
    private void CheckGroundedWithRaycast()
    {
        if (inputController.moveInput.x < 0f)
        {
            //Check left to right
            for (int i = 0; i < groundCheckers.Length; i++)
            {
                var checker = groundCheckers[i];

                if (checker.CheckForFloor(maxWalkAngle, out var hitCollider, out targetPlatformAngle))
                {
                    GroundDetected(hitCollider.gameObject);
                    return;
                }
            }
        }
        else if (inputController.moveInput.x > 0f)
        {
            //Check right to left.
            for (int i = groundCheckers.Length - 1; i >= 0; i--)
            {
                var checker = groundCheckers[i];

                if (checker.CheckForFloor(maxWalkAngle, out var hitCollider, out targetPlatformAngle))
                {
                    GroundDetected(hitCollider.gameObject);
                    return;
                }
            }
        }
        else
        {

            for (int i = 0; i < groundCheckers.Length; i++)
            {
                var checker = groundCheckers[i];

                if (checker.CheckForFloor(maxWalkAngle, out var hitCollider, out targetPlatformAngle))
                {
                    GroundDetected(hitCollider.gameObject);
                    return;
                }
            }
        }

        OnGroundExit();
    }

    private void Walking()
    {
        float inputX = inputController.moveInput.x;
        float absInputX = Mathf.Abs(inputX);
        float blend = Mathf.Clamp01(accelerationRate * Time.fixedDeltaTime);

        float angleRad = currentPlatformAngle * Mathf.Deg2Rad;
        Vector2 normal = new Vector2(Mathf.Sin(angleRad), Mathf.Cos(angleRad));
        Vector2 tangent = new Vector2(normal.y, -normal.x).normalized;

        float dir = inputController.moveInput.x;
        Vector2 targetVelocity = tangent * moveSpeed * dir;

        if (Mathf.Abs(currentPlatformAngle) >= walkHelpAngle)
        {
            rb.linearVelocity = new Vector2(Vector2.Lerp(rb.linearVelocity, targetVelocity, Vector2.Distance(rb.linearVelocity, targetVelocity)).x, targetVelocity.y);
        } else
        {
            rb.linearVelocity = new Vector2(Vector2.Lerp(rb.linearVelocity, targetVelocity, Vector2.Distance(rb.linearVelocity, targetVelocity)).x, rb.linearVelocityY);
        }
    }

    private void AirControl()
    {
        // Scale the force based on how close the player's x velocity is to maxAirSpeed
        float currentVelocityX = rb.linearVelocityX;
        float inputDirection = Mathf.Sign(inputController.moveInput.x);
        if (inputController.moveInput.x == 0f)
        {
            inputDirection = 0f;
        }
        float velocityDirection = Mathf.Sign(currentVelocityX);
        float absCurrentAirSpeed = Mathf.Abs(currentVelocityX);

        // Allow full force if changing direction or stopping, limit only when accelerating further in current direction
        bool acceleratingInSameDirection = (inputDirection == velocityDirection);
        float scaledForce = inputController.moveInput.x * airControlPower;
        if (!acceleratingInSameDirection && Mathf.Abs(currentVelocityX) > 0.1 && Mathf.Abs(inputController.moveInput.x) > 0.01f)
        {
            rb.AddForce(new Vector2((scaledForce * airControlPower), 0f), ForceMode2D.Force);
        } else
        {
            float airSpeedRatio = 1 - (absCurrentAirSpeed / maxAirSpeed);
            rb.AddForce(new Vector2((scaledForce * airControlPower)*airSpeedRatio, 0f), ForceMode2D.Force);
            return;
        }
    }
    public void Climb()
    {
        Debug.Log("Climb input received");
        Debug.Log($"Current State: {currentState}, isBuilding: {isBuilding}, isDisabled: {isDisabled}");
        if (currentState != PlayerState.Climbing && !isBuilding && !isDisabled)
        {
            if (currentStamina > 0f)
            {
                Collider2D[] colliders = Physics2D.OverlapCircleAll(playerCollider.bounds.center, playerCollider.bounds.extents.x, LayerMask.GetMask("Climbable"));
                if (colliders.Length > 0)
                {
                    UpdatePlayerState(PlayerState.Climbing);
                    timeInAir = 0f;
                    rb.gravityScale = 0f;
                    rb.linearVelocity = Vector2.zero;
                    currentClimbable = colliders[0].gameObject;
                    climbingAnchor = new GameObject("ClimbingAnchor");
                    climbingAnchor.transform.position = transform.position;
                    climbingAnchor.transform.SetParent(currentClimbable.transform);
                }
            }
        }
    }
    private void StopClimbing(bool activelyStopped)
    {
        UpdatePlayerState(PlayerState.Falling);
        Destroy(climbingAnchor);
        climbingAnchor = null;
        rb.gravityScale = 1f;
        rb.simulated = true;
        rb.linearVelocity = currentClimbVelocity*2;
        if (inputController.moveInput.magnitude > 0.1f && currentStamina > 0f && activelyStopped)
        {
            float climbJumpMultiplier = Mathf.Clamp(currentStamina, 0.2f, 1f);
            rb.AddForce(Vector2.up * jumpForce, ForceMode2D.Impulse);
            rb.linearVelocity = new Vector2(rb.linearVelocityX, climbJumpMultiplier * jumpForce);
            currentStamina = Mathf.Max(currentStamina - climbJumpMultiplier, 0f);
            currentState = PlayerState.Jumping;
            jumpInputTime = 0f;
        }
    }
    private void ClimbingMovement()
    {
        Vector3 climbDirection = new Vector3(inputController.moveInput.x, inputController.moveInput.y, 0).normalized;
        bool successfulMove = true;
        Collider2D[] overlappingColliders = Physics2D.OverlapCapsuleAll(climbingAnchor.transform.position + climbDirection * climbSpeed * Time.deltaTime, playerCollider.bounds.size - (Vector3)Vector2.one * 0.5f, CapsuleDirection2D.Vertical, 0f, LayerMask.GetMask("Ground"));
        List<Collider2D> colliderList = new List<Collider2D>(overlappingColliders);
        // Remove the colliders that are platform effectors
        colliderList.RemoveAll(col => col != null && col.gameObject.TryGetComponent(out PlatformEffector2D platformEffector));

        if (!(colliderList.Count > 0))
        {
            climbingAnchor.transform.position = climbingAnchor.transform.position + climbDirection * climbSpeed * Time.deltaTime;
        } else
        {
            Debug.Log(colliderList[0].name);
            successfulMove = false;
        }
        transform.position = climbingAnchor.transform.position;
        if (inputController.moveInput.magnitude > 0 && successfulMove)
        {
            ChangeStamina(-1f * Time.deltaTime);
        } else
        {
            ChangeStamina(-restingDrainRate * Time.deltaTime);
        }
        if (currentStamina <= 0f)
        {
            StaminaDepleted();
            return;
        }
        Collider2D[] colliders = Physics2D.OverlapCircleAll(playerCollider.bounds.center, playerCollider.bounds.extents.x, LayerMask.GetMask("Climbable"));
        if (colliders.Length > 0)
        {
            if (colliders[0].transform != climbingAnchor.transform.parent)
            {
                climbingAnchor.transform.SetParent(colliders[0].transform);
                currentClimbable = colliders[0].gameObject;
            }
        }
        else
        {
            StopClimbing(false);
        }
    }

    public void Jump()
    {
        if (currentState == PlayerState.Climbing)
        {
            StopClimbing(true);
        }
        if (currentState == PlayerState.Walking || (currentState == PlayerState.Falling && timeInAir <= coyoteTime))
        {
            if (inputController.moveInput.y <= -0.7f && !ignoredPlatforms.Contains(currentGroundedPlatform))
            {
                DropDownPlatform();
            }
            else
            {
                Debug.Log("Jump executed");
                rb.AddForce(Vector2.up * jumpForce, ForceMode2D.Impulse);
                rb.linearVelocity = new Vector2(rb.linearVelocityX, 1 * jumpForce);
                UpdatePlayerState(PlayerState.Jumping);
                jumpInputTime = 0f;
            }
        }
    }

    public void DropDownPlatform()
    {
        if (currentGroundedPlatform != null)
        {
            PlatformEffector2D platformEffector = currentGroundedPlatform.GetComponent<PlatformEffector2D>();
            if (platformEffector != null)
            {
                StartCoroutine(DisablePlatformCollisionTemporarily(currentGroundedPlatform.GetComponent<Collider2D>()));
            }
        }
    }
    private IEnumerator DisablePlatformCollisionTemporarily(Collider2D platform)
    {
        ignoredPlatforms.Add(platform.gameObject);
        Physics2D.IgnoreCollision(playerCollider, platform, true);
        Physics2D.IgnoreCollision(feetCollider, platform, true);
        yield return new WaitForSeconds(1f);
        Physics2D.IgnoreCollision(playerCollider, platform, false);
        Physics2D.IgnoreCollision(feetCollider, platform, false);
        ignoredPlatforms.Remove(platform.gameObject);
    }
    public void Interact()
    {
        if (validInteraction != null && validInteraction.IsInteractable())
        {
            PlayerInputController.SwitchActionMap(validInteraction.ActionMapToSwapTo());
            validInteraction.Interact();
        }
    }

    private Vector2 CalculateVelocity()
    {
        if (previousPosition != (Vector2)transform.position)
        {
            Vector2 velocity = (Vector2)transform.position - previousPosition;
            previousPosition = (Vector2)transform.position;
            Debug.Log("CurrentVelocity is: " + velocity / Time.deltaTime);
            return velocity / Time.deltaTime;
        } else
        {
            Debug.Log("Velocity is Zero");
            return Vector2.zero;
        }
    }
    private void TimeEffects()
    {
        jumpInputTime += Time.fixedDeltaTime;
        if (currentState == PlayerState.Jumping || currentState == PlayerState.Falling)
        {
            timeInAir += Time.fixedDeltaTime;
            if (currentState == PlayerState.Jumping && timeInAir > timeBeforeDownforce && inputController.jumpInput == 0)
            {
                rb.AddForce(Vector2.down * downForce, ForceMode2D.Force);
            }
        }
        if (currentState == PlayerState.Walking)
        {
            if (currentStamina < maxStamina)
            {
                currentStamina += Time.fixedDeltaTime;
                if (currentStamina > maxStamina)
                {
                    currentStamina = maxStamina;
                }
            }
        }
    }

    public float GetCurrentStamina()
    {
        return currentStamina;
    }

    public float GetMaxStamina()
    {
        return maxStamina;
    }

    private void UpdateAnimVars()
    {
        if (currentState == PlayerState.Climbing)
        {
            animator.SetFloat("yVelocity", currentClimbVelocity.y);
            animator.SetFloat("xVelocity", currentClimbVelocity.x);
        }
        else
        {
            animator.SetFloat("yVelocity", rb.linearVelocityY);
            //Fix for Unity's Animation system improperly switching animation states
            float currentAnimX = animator.GetFloat("xVelocity");
            if (currentAnimX is < 0.001f and > -0.001f && currentAnimX != 0f)
            {
                animator.SetFloat("xVelocity", 0f);
            }
            else
            {
                animator.SetFloat("xVelocity", Mathf.Lerp(currentAnimX, rb.linearVelocityX, Time.deltaTime * 20));
            }

        }

        
    }

    public void ChangeStamina(float amount)
    {
        currentStamina += amount;
        currentStamina = Mathf.Clamp(currentStamina, 0f, maxStamina);
    }
    private void StaminaDepleted()
    {
        StopClimbing(false);
        currentStamina = 0f;
    }
    private void GroundDetected(GameObject groundObject)
    {
        if (groundObject != null && jumpInputTime > jumpBuffer)
        {
            currentGroundedPlatform = groundObject;
            if (currentState != PlayerState.Climbing && currentState != PlayerState.Walking)
            {
                UpdatePlayerState(PlayerState.Walking);
            }
            timeInAir = 0f;
        }
    }
    private void OnGroundExit()
    {
        if (currentState != PlayerState.Jumping && currentState != PlayerState.Climbing)
        {
            UpdatePlayerState(PlayerState.Falling);
        }
        currentGroundedPlatform = null;
        targetPlatformAngle = 0f;
    }

    public void SetBuildMode(bool isBuildingMode)
    {
        isBuilding = isBuildingMode;
        if (isBuildingMode)
        {
            //Note: we switch to the Building action map once the user selects an inventory item from the UI.
            PlayerInputController.SwitchActionMap(PlayerInputController.ActionMap.UI);
        }
        else
        {
            PlayerInputController.SwitchActionMap(PlayerInputController.ActionMap.Platforming);
        }

        buildingController.TogglePlacementGuide(isBuildingMode);
        Debug.Log($"Build mode toggled. isBuilding: {isBuilding}");
    }

    [ContextMenu("Sort")]
    private void Sort()
    {
        var l = groundCheckers.ToList();
        l.Sort(((checker, floorChecker) =>
        {
            var x1 = checker.transform.position.x;
            var c2 = floorChecker.transform.position.x;

            if (x1 == c2)
                return 0;
            else if (x1 < c2)
                return -1;
            else return 1;
        }));
        groundCheckers = l.ToArray();

        for (int i = 0; i < groundCheckers.Length; i++)
        {
            groundCheckers[i].transform.SetSiblingIndex(i);
        }
    }
}

Overview

The player in FCR is Bekka, a postal squirrel that has all the normal platforming abilities you know and love, but with the addition of being able to place and climb blocks. She gathers a backpack full of blocks and uses them to get as high as possible up the mountain, all in service of helping the mountain residents.

The main bit of the controller I’d like to cover is the climbing movement.

Climbing

As input is handled outside the script, Climb() begins by checking the player’s state. We went with an additive scene structure where the player is always loaded and ready, with the levels loading and unloading around them. We also check the player’s stamina as it’s drained when climbing, forcing the player to strategically build around platforms to forge a path forwards.

Once initial checks are complete, we then check with a circle cast to see if the player is over a climbable surface, if so we change their state and transfer over to the climbing movement.

    public void Climb()
    {
        Debug.Log("Climb input received");
        Debug.Log($"Current State: {currentState}, isBuilding: {isBuilding}, isDisabled: {isDisabled}");
        if (currentState != PlayerState.Climbing && !isBuilding && !isDisabled)
        {
            if (currentStamina > 0f)
            {
                Collider2D[] colliders = Physics2D.OverlapCircleAll(playerCollider.bounds.center, playerCollider.bounds.extents.x, LayerMask.GetMask("Climbable"));
                if (colliders.Length > 0)
                {
                    UpdatePlayerState(PlayerState.Climbing);
                    timeInAir = 0f;
                    rb.gravityScale = 0f;
                    rb.linearVelocity = Vector2.zero;
                    currentClimbable = colliders[0].gameObject;
                    climbingAnchor = new GameObject("ClimbingAnchor");
                    climbingAnchor.transform.position = transform.position;
                    climbingAnchor.transform.SetParent(currentClimbable.transform);
                }
            }
        }
    }

Climbing movement is done through an ‘anchor’. It’s an empty gameobject that the player is attached to, that is in turn a child of the object the player is climbing. This allows us to do our intended movement, experiment with how to move the player (such as physics joints or interpolation), and avoid rotating the player while still having them track the movement of the block underneath as they are still physically simulated.

When climbing, the player’s input is mapped to moving the climbing anchor. It simultaneously checks if there is a valid climbable surface under the player, kicking them off if there isn’t. It also continually drains stamina, more so if the player is moving.

private void ClimbingMovement()
    {
        Vector3 climbDirection = new Vector3(inputController.moveInput.x, inputController.moveInput.y, 0).normalized;
        bool successfulMove = true;
        Collider2D[] overlappingColliders = Physics2D.OverlapCapsuleAll(climbingAnchor.transform.position + climbDirection * climbSpeed * Time.deltaTime, playerCollider.bounds.size - (Vector3)Vector2.one * 0.5f, CapsuleDirection2D.Vertical, 0f, LayerMask.GetMask("Ground"));
        List<Collider2D> colliderList = new List<Collider2D>(overlappingColliders);
        // Remove the colliders that are platform effectors
        colliderList.RemoveAll(col => col != null && col.gameObject.TryGetComponent(out PlatformEffector2D platformEffector));

        if (!(colliderList.Count > 0))
        {
            climbingAnchor.transform.position = climbingAnchor.transform.position + climbDirection * climbSpeed * Time.deltaTime;
        } else
        {
            Debug.Log(colliderList[0].name);
            successfulMove = false;
        }
        transform.position = climbingAnchor.transform.position;
        if (inputController.moveInput.magnitude > 0 && successfulMove)
        {
            ChangeStamina(-1f * Time.deltaTime);
        } else
        {
            ChangeStamina(-restingDrainRate * Time.deltaTime);
        }
        if (currentStamina <= 0f)
        {
            StaminaDepleted();
            return;
        }
        Collider2D[] colliders = Physics2D.OverlapCircleAll(playerCollider.bounds.center, playerCollider.bounds.extents.x, LayerMask.GetMask("Climbable"));
        if (colliders.Length > 0)
        {
            if (colliders[0].transform != climbingAnchor.transform.parent)
            {
                climbingAnchor.transform.SetParent(colliders[0].transform);
                currentClimbable = colliders[0].gameObject;
            }
        }
        else
        {
            StopClimbing(false);
        }
    }

using System;
using System.Collections.Generic;
using UnityEngine;
using DG.Tweening;
using System.Linq;

public class BuildingController : MonoBehaviour
{
    public static BuildingController Instance { get; private set; }

    private GameObject currentBlock;
    private Item currentItem;
    private Sprite blockSprite;
    private SpriteRenderer sr;
    private Rigidbody2D rb;
    private BlockProperties blockProperties;
    private PolygonCollider2D pc;
    private InventorySlot invSlot;

    [Header("Visuals")]
    [SerializeField] private Color baseColor = Color.white;
    [SerializeField] private Color collidingColor = Color.red;
    [SerializeField] private SpriteRenderer placementIndicator;
    [SerializeField] private Transform placementMask;
    [SerializeField] private float placementIndicatorFadeSpeed = 0.3f;
    [SerializeField] private Color validPlacementColor = Color.coral;
    [SerializeField] private Color invalidPlacementColor = Color.red;

    [Header("Controls")]
    public float rotateSpeed = 200f;

    [Header("Placement")]
    [SerializeField] private FMODUnity.EventReference invalidPlacementSound;
    [SerializeField] private float validPlacementRadius = 8f;
    private RaycastHit2D[] castResults = new RaycastHit2D[16];
    private List<GameObject> placedBlocks = new List<GameObject>();
    private List<String> placedBlockIDs = new List<String>();

    private void OnEnable()
    {
        placementMask.localScale = new Vector3(validPlacementRadius * 2, validPlacementRadius * 2, 1);
        if (Instance == null)
        {
            Instance = this;
        }
        else
        {
            Destroy(this);
            return;
        }
    }
    private void Update()
    {
        if (currentBlock != null)
        {
            MoveBlock();
            RotateBlock();
        }
    }
    public void EquipBlock(Item item, InventorySlot inventorySlot)
    {
        if (currentBlock != null)
        {
            UnequipBlock();
        }
        currentItem = item;
        invSlot = inventorySlot;
        currentBlock = Instantiate(item.itemBase.objectToSpawn, GetCenterWorldPos(), Quaternion.identity);
        rb = currentBlock.GetComponent<Rigidbody2D>();
        blockProperties = currentBlock.GetComponent<BlockProperties>();
        if (currentBlock.TryGetComponent<SpriteRenderer>(out SpriteRenderer spriteRenderer))
        {
            sr = spriteRenderer;
        } else if (currentBlock.GetComponentInChildren<SpriteRenderer>() != null)
        {
            sr = currentBlock.GetComponentInChildren<SpriteRenderer>();
        }
        pc = currentBlock.GetComponent<PolygonCollider2D>();
        rb.simulated = false;
    }

    private Vector3 GetMouseWorldPos()
    {
        Vector3 worldPos = Camera.main.ScreenToWorldPoint(PlayerCharacterController.Instance.inputController.pointerInput);
        worldPos.z = 0;
        return worldPos;
    }

    private Vector3 GetCenterWorldPos()
    {
        var center = Camera.main.ViewportToWorldPoint(new Vector3(0.5f, 0.5f));
        center.z = 0f;
        return center;
    }

    private void MoveBlock()
    {
        currentBlock.transform.position = GetMouseWorldPos();
        if (IsValidPlacement())
        {
            sr.color = baseColor;
        }
        else
        {
            sr.color = collidingColor;
        }
    }
    private void RotateBlock()
    {
        float scroll = PlayerCharacterController.Instance.inputController.scrollInput;
        float desiredRotation = rotateSpeed * scroll * Time.deltaTime;

        currentBlock.transform.rotation *= Quaternion.Euler(0, 0, desiredRotation);
    }
    public bool IsValidPlacement()
    {
        if (Vector3.Distance(currentBlock.transform.position, PlayerCharacterController.Instance.transform.position) > validPlacementRadius)
        {
            placementIndicator.DOColor(invalidPlacementColor, placementIndicatorFadeSpeed).SetEase(Ease.InOutSine);
            return false;
        }
        Collider2D[] blockColliders;
        if (currentBlock.GetComponents<Collider2D>().Length > 0)
        {
            blockColliders = currentBlock.GetComponents<Collider2D>();
        } else
        {
            blockColliders = currentBlock.GetComponentsInChildren<Collider2D>();
        }
        if (blockColliders.Length > 0)
        {
            foreach (Collider2D col in blockColliders)
            {
                List<Collider2D> overlapResults = new List<Collider2D>();
                Physics2D.OverlapCollider(col, new ContactFilter2D(), overlapResults);
                for (int i = overlapResults.Count - 1; i >= 0; i--)
                {
                    if (overlapResults[i].gameObject == currentBlock || overlapResults[i].gameObject == PlayerCharacterController.Instance.gameObject || overlapResults[i].transform.parent == PlayerCharacterController.Instance.gameObject.transform || overlapResults[i].gameObject.layer == LayerMask.NameToLayer("BlocksPlayer"))
                    {
                        overlapResults.RemoveAt(i);
                    }
                }
                if (overlapResults.Count != 0)
                {
                    if (blockProperties.isSticky)
                    {
                        for (int i = 0; i < overlapResults.Count; i++)
                        {
                            if (overlapResults[i].gameObject.TryGetComponent(out BlockProperties overlapProperties))
                            {
                                if (!blockProperties.sticksTo.Contains(overlapProperties.blockType))
                                {
                                    placementIndicator.color = invalidPlacementColor;
                                    return false;
                                }
                            } else
                            {
                                placementIndicator.color = invalidPlacementColor;
                                return false;
                            }
                        }
                    } else
                    {
                        placementIndicator.color = invalidPlacementColor;
                        return false;
                    }
                }
            }
            castResults = new RaycastHit2D[16];
            Physics2D.Raycast(currentBlock.transform.position, Vector2.zero, new ContactFilter2D(), castResults);
            if (blockProperties.isSticky && castResults[0].collider == null)
            {
                placementIndicator.color = invalidPlacementColor;
                return false;
            }
            for (int i = 0; i < castResults.Length; i++)
            {
                if (castResults[i].collider != null && castResults[i].collider.gameObject != currentBlock)
                {
                    if (castResults[i].collider.TryGetComponent(out BlockProperties castProperties))
                    {
                        if (!blockProperties.sticksTo.Contains(castProperties.blockType))
                        {
                            placementIndicator.color = invalidPlacementColor;
                            return false;
                        }
                    }
                    else
                    {
                        placementIndicator.color = invalidPlacementColor;
                        return false;
                    }
                }
            }
            placementIndicator.color = validPlacementColor;
            return true;
        }
        Debug.LogError("You shouldn't be here! Probably need to add a collider to the block");
        return false;
    }
    public void PlaceBlock()
    {
        if (currentBlock == null)
        {
            Debug.LogError("[BuildingController] currentBlock is null while calling PlaceBlock();");
            return;
        }

        if (IsValidPlacement())
        {
            if (blockProperties.isSticky)
            {
                Debug.Log("Found: " + Physics2D.Raycast(currentBlock.transform.position, Vector2.zero).collider.gameObject.name); 
                List<RaycastHit2D> stickyCastResults = new List<RaycastHit2D>();
                Physics2D.Raycast(currentBlock.transform.position, Vector2.zero, new ContactFilter2D(), stickyCastResults);
                if (stickyCastResults.Count == 0)
                {
                    FMODUnity.RuntimeManager.PlayOneShot(invalidPlacementSound, currentBlock.transform.position);
                    return;
                }
                for (int i = 0; i < stickyCastResults.Count; i++)
                {
                    if (stickyCastResults[i].collider == null)
                    {
                        continue;
                    }
                    if (stickyCastResults[i].collider != null && stickyCastResults[i].collider.gameObject != currentBlock)
                    {
                        if (stickyCastResults[i].collider.TryGetComponent(out BlockProperties castProperties))
                        {
                            if (blockProperties.sticksTo.Contains(castProperties.blockType))
                            {
                                currentBlock.transform.SetParent(stickyCastResults[i].transform);
                                rb.bodyType = RigidbodyType2D.Kinematic;
                                Physics2D.IgnoreCollision(currentBlock.GetComponent<Collider2D>(), stickyCastResults[i].collider);
                            }
                        }
                    }
                }
            }
            rb.simulated = true;
            placementIndicator.color = invalidPlacementColor;
            if (!blockProperties.customSoundEvent.IsNull)
            {
                FMODUnity.RuntimeManager.PlayOneShot(blockProperties.customSoundEvent, currentBlock.transform.position);
            }
            placedBlocks.Add(currentBlock);
            placedBlockIDs.Add(currentItem.itemBase.id);
            invSlot.ProcessBlockAction(true);
            currentBlock = null;
        }
        else
        {
            FMODUnity.RuntimeManager.PlayOneShot(invalidPlacementSound, currentBlock.transform.position);
        }
    }
    public void UnequipBlock()
    {
        if (currentBlock != null)
        {
            invSlot.ProcessBlockAction(false);
            Destroy(currentBlock);
            currentBlock = null;
        }
        else
        {
            if(placementIndicator)
                placementIndicator.color = invalidPlacementColor;
        }
    }

    public void TogglePlacementGuide(bool visible)
    {
        placementIndicator.DOKill();
        if (visible)
        {
            placementIndicator.color = invalidPlacementColor;
        }
        else
        {
            placementIndicator.DOFade(0f, placementIndicatorFadeSpeed).SetEase(Ease.InOutSine);
        }
    }

    public List<GameObject> GetPlacedBlocks()
    {
        return placedBlocks;
    }
    
    public List<String> GetPlacedBlockIDs()
    {
        return placedBlockIDs;
    }

    public void ClearPlacedBlocks()
    {
        while (placedBlocks.Count > 0)
        {
            GameObject blockToRemove = placedBlocks[0];
            placedBlocks.RemoveAt(0);
            Destroy(blockToRemove);
        }
        placedBlocks.Clear();
    }
    
    public void ClearPlacedBlockIDs()
    {
        while (placedBlockIDs.Count > 0)
        {
            String blockIDToRemove = placedBlockIDs[0];
            placedBlockIDs.RemoveAt(0);
        }
        placedBlockIDs.Clear();
    }

    public void ReturnPlacedBlocks()
    {
        while (placedBlockIDs.Count > 0)
        {
            InventoryItemBase blockItem = ItemDatabase.instance.GetItemFromID(placedBlockIDs[0]);
            if (blockItem != null)
            {
                Debug.Log("Adding item back to inv:" + blockItem.itemName);
                InventoryManager.instance.AddToInventory(blockItem);
                placedBlockIDs.RemoveAt(0);
                Destroy(placedBlocks[0]);
                placedBlocks.RemoveAt(0);
            }
            
        }
        placedBlockIDs.Clear();
        placedBlocks.Clear();
    }

    // Used for tutorial blocks. These blocks are not supposed to be as part of the main ItemDatabase, so providing reference to what block to give back.
    public void ReturnPlacedTutorialBlock(GameObject blockToReturn, InventoryItemBase itemBase)
    {
        if (placedBlocks.Contains(blockToReturn))
        {
            placedBlocks.Remove(blockToReturn);
            InventoryManager.instance.AddToInventory(itemBase);
            Destroy(blockToReturn);
        }
    }

}