Unity Architecture: Spaghetti Pattern

Simon Nordon
13 min readSep 22, 2023

--

Six months ago I was promoted to the position of Lead Unity Developer. Taking on this role, I felt that I had to level up my coding game. I was tired of creating glorified prototypes, that impressed clients and employers early, only for the codebase to become a hell scape 12 months later, riddled with bugs and mountains of technical debt.

However, wherever I looked, and whomever I asked, the consensus was the same. I shouldn’t be chasing such silly dreams, a Singleton Game Manager is all I’d ever need to build Unity apps.

I didn’t like that answer, and hope that it isn’t the definitive one. So I decided to challenge myself to make a game, not for fame or money, but to explore the very nature of how to build a game. In the hopes of finding an Architecture Pattern that would lead to something greater than a big bowl of spaghetti.

Ironically, I started the only way I knew how, by whipping up a Prototype, for in order to escape the spaghetti, one must know the spaghetti. But what exactly is Spaghetti Code? There is no singular definition, but there’s two I tend to envision.

  1. Spaghetti Code doesn’t follow (the spirit of) SOLID. Methods and classes are long and complex, they can’t be easily changed without breaking other parts of the app, or causing strange bugs, and you can’t seem to isolate a piece of code without dragging the rest of the application in with it.
  2. The second, less common definition, is thinking about your codebases Dependency Graph. A good dependency graph looks like a Christmas Tree, or a pyramid. It starts from a composition root and travels down, and only down, the hierarchy. Not too deep, and not too wide. A Spaghetti code base doesn’t have this structure, it has many criss-crossing dependencies, such that when you draw lines between all your classes indicating their dependencies, it looks like a big mess of, well, Spaghetti.
Clean vs Spaghetti Dependency Graph

I took this challenge one step further. Not only am I going to build the game as a Prototype, I’m also going to take myself back to when I was a novice developer, and only use the techniques I had at the time. That means no events, no interfaces or fancy frameworks. Just Monobehaviours, Coroutines, Singletons and Prefabs.

I did this, because this prototype will be the foundation upon which we delve into newer, more respectable Architectures. We start with the counter example of scalable, performant code, so we can better learn how to escape it.

I decided to build a Vampire Survivors inspired clone for this experiement, as it’s easy to build and fun to play. Here’s a glimpse of the final product.

I’m calling it “Slime Survivors”

It took 53 hours to complete, with each minute of development being meticulously recorded.

Discipline

Let’s take a look at the journey to get to this position.

First 30 Minutes

One advantage of the Spaghetti pattern is that it allows us to get right into building the game. There’s no planning stage, and within 30 minutes we already have a semblance of the core game.

We’re basically done right?

Let’s also take this opportunity to begin visualizing the dependency graph I talked about earlier.

nice

Each circle represents a class, with the arrows representing a direct reference.

At the moment, the dependency graph is actually looking well structured, with a clear directionality.

Hour 6

pew pew

After 6 hours we’ve added UI, Player attacks, and chests to pick up new items.

Following the rules of the spaghetti pattern, and the suggestions of my fellow Unity Developers, the expedient class we needed to achieve this is the Singleton Game Manager, which currently looks like this.

Ol’ Reliable

And here is the updated dependency graph.

I can deal with this

A light blue color indicates an object is using the Singleton pattern, a diamond shape indicates a Scriptable Object (data) and the round rectangle is a UI element.

It only took us 6 hours to turn our dependency graph into Spaghetti. For small projects though, this is fine. We can even excuse the circular dependencies between the Game Manager and Player Controller.

Hour 28

I went ahead and added support for persistent data, so that we could have things like Achievements, permanent upgrades and statistics.

It was around hour 16 that I ended up fixing my first bug. But so far, things have been going smoothly. Only 1.5 hours of the last 28 has been spent fixing any kind of bug, or 5.6% of our total time.

Let’s check the updated Dependency Graph.

organic growth

Even at this point, there’s probably little cause for concern. We’re seeing a few bugs pop up, but nothing we can’t handle.

Hour 53

53 hours

Ignoring the obvious change in visuals, which I’ll discuss in a later post, there hasn’t been much major functionality added to the game. Mostly polish, content and balancing.

6.3 hours were spent on bug fixes which is 25% of the development time since our last update, a massive jump from the previous 5.3%

Here is the snapshot of the final Game Manager.

A tribute to Unity Projects everywhere

And our dependency graph.

Spaghetti Pattern Exhibit A

Once you get to the point of not being able to visualise your dependency graph without crossing lines, you’ve truly embraced the Spaghetti Pattern.

Before we move on, there’s one particular feature I want to point out, which was one of the last features implemented.

Just an cute little progress bar.

This progress bar is just meant to show you how far through the game you’ve made it. It also took 5 hours to create, or 10% of the total development time.

The code has become so complex at this stage, so brittle, I felt I was easier to build a progress system on top of the existing game, rather than just making it apart of the game, as it should have been.

Data Time

Let’s take a look at a breakdown of tasks over the 53 hours.

What does it look like at the limit?

We’re tracking total time dedicated to new features, refactoring and bug fixing. We maintain maximum velocity until 15 hours, then we start paying our technical debt. Over time the proportion of time spent on Bug Fixes (green) is increasing.

This is only in the first 50 hours of a project, I can tell you from experience, given enough time, the bug fixes will not only catch up to features, but leave it in the dust.

Conclusion

Look, it’s great for a codebase to be scalable and performant, but we still need to actually finish the game, and for that the Spaghetti pattern is great, because at the end of the day, the player doesn’t care how pretty your code is.

However, there’s a difference between writing Spaghetti code because you want to be fast, versus writing Spaghetti code because you don’t know how to code any other way.

We have a great starting point to go and explore these better Architecture Patterns, and I’ll have to the first of many published soon.

In the meantime, why don’t you try the game for yourself.

If you want to see another example of Spaghetti code, check out the decomplication of Vampire Survivors:

You can check out the full source code to this pattern, including other patterns, on my github.

Finally, I wanted to leave you with the source code of the PlayerController.cs, just so you can appreciate what true Spaghetti code looks like.

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

[DefaultExecutionOrder(10)]
public class PlayerController : MonoBehaviour
{
private Transform _transform;

public Camera camera;
private Vector3 _cameraOffset;

public Vector3 targetDirection;

[Header("References")]
public GameManager gameManager;
public EnemyManager enemyManager;


[Header("Pistol")]
public GameObject projectilePrefab;
private float _timeSinceLastFire = 0.0f;
private Transform _closestTarget = null;

[Header("Sword")]
public Transform swordPivot;
private float _timeSinceLastSwing = 0.0f;
private bool _isSwingingLeftToRight = true;
private bool _isSwordAttacking = false;

[Header("UI")]
public TextMeshProUGUI healthText;
public Image healthBar;
public Transform localCanvas;
public GameObject dodgeTextGo;
public GameObject damageTextGo;
private Quaternion _startRotation;

private Coroutine _swordCoroutine;
private Coroutine _dodgeTextCoroutine;
private Coroutine _damageTextCoroutine;
private Coroutine _dashCoroutine;
private bool _isDashing;
private bool _canTakeDamage = true;

[Header("Dash")]
public float dashDistance = 5f;
public float dashTime = 0.2f;

[Header("Effects")]
public ParticleSystem shootEffect;
public ParticleSystem dashParticle;
public ParticleSystem reviveParticle;

[Header("Debug")] public bool isDebugMode = false;

[Header("Sound")]
public SoundDefinition swordSound;
public SoundDefinition shootSound;
public SoundDefinition dashSound;
public SoundDefinition deathSound;
public SoundDefinition healthPackSound;
public SoundDefinition takeDamageSound;
public SoundDefinition blockSound;

private void Awake()
{
_transform = transform;
_cameraOffset = camera.transform.position - _transform.position;
}

private void Start()
{
swordPivot.transform.parent = null;
swordPivot.gameObject.SetActive(false);
_startRotation = localCanvas.rotation;
SetUI();
}

private IEnumerator Dash()
{
_isDashing = true;
gameManager.dashes.value--;

var elapsedTime = 0f;
var startPosition = transform.position;
var dashDestination = transform.forward * dashDistance + transform.position;

while (elapsedTime < dashTime)
{
elapsedTime += Time.deltaTime;

var normalizedTime = elapsedTime / dashTime;
var inverseQuadraticTime = 1 - Mathf.Pow(1 - normalizedTime, 2);

var desiredPos = Vector3.Lerp(startPosition, dashDestination, inverseQuadraticTime);

// clamp the position to the level bounds
transform.position = new Vector3(Mathf.Clamp(desiredPos.x, -gameManager.levelBounds.x, gameManager.levelBounds.x),
transform.position.y,
Mathf.Clamp(desiredPos.z, -gameManager.levelBounds.y, gameManager.levelBounds.y));

if (!gameManager.isGameActive)
{
_isDashing = false;
yield break;
}

yield return new WaitForEndOfFrame();
}

_isDashing = false;

}

private void Update()
{
if(!GameManager.instance.isGameActive) return;

if (Input.GetKeyDown(KeyCode.Space) && (int)gameManager.dashes.value > 0 && !_isDashing)
{
AudioManager.instance.PlaySound(dashSound);
dashParticle.Play();
_dashCoroutine = StartCoroutine(Dash());
}

swordPivot.transform.position = _transform.position;

if (_isDashing) return;

// Get Closest enemy target.
var closestDistance = Mathf.Infinity;
var targetIsNull = true;
_closestTarget = null;
foreach (var enemy in enemyManager.enemies)
{
var distance = Vector3.Distance(_transform.position, enemy.transform.position);
// now we minus the radius of the enemy from the distance, so that we get the distance to its edge.
distance -= enemy.transform.localScale.x * 0.5f;
if (distance < closestDistance)
{
closestDistance = distance;
_closestTarget = enemy.transform;
targetIsNull = false;
}
}

if (!targetIsNull)
{
targetDirection = Vector3.ProjectOnPlane(_closestTarget.position - _transform.position, Vector3.up).normalized;
if(targetDirection.magnitude < 0.1f) targetDirection = transform.forward;
}
else
{
targetDirection = transform.forward;
}

// Fire Pistol if possible.
_timeSinceLastFire += Time.deltaTime;
if (_timeSinceLastFire > 1 / gameManager.pistolFireRate.value)
{
if (!targetIsNull)
{
if (closestDistance <= gameManager.pistolRange.value)
{
var isKnockBack = false;
if(_closestTarget.TryGetComponent<EnemyController>(out var enemyController))
{
isKnockBack = enemyController.isKnockedBack;
}

AudioManager.instance.PlaySound(shootSound);
shootEffect.Play();
if (isKnockBack)
{
Shoot();
}
else
{
ShootPredictive();
}

}
}
}

// Swing sword if possible.
_timeSinceLastSwing += Time.deltaTime;
if (_timeSinceLastSwing > 1 / gameManager.swordAttackSpeed.value && !_isSwordAttacking)
{
if (!targetIsNull)
{
if (closestDistance <= gameManager.swordRange.value)
{
if(_swordCoroutine != null) StopCoroutine(_swordCoroutine);
AudioManager.instance.PlaySound(swordSound);
_swordCoroutine = StartCoroutine(SwordAttack());
_timeSinceLastSwing = 0.0f;
}
}
}

var dir = Vector3.zero;

if (Input.GetKey(KeyCode.A))
dir += Vector3.left;
if (Input.GetKey(KeyCode.D))
dir += Vector3.right;
if (Input.GetKey(KeyCode.W))
dir += Vector3.forward;
if (Input.GetKey(KeyCode.S))
dir += Vector3.back;

// Check if the player is at the level bounds, if they are, make sure they cant move in the direction of the bound
if (_transform.position.x <= -gameManager.levelBounds.x && dir.x < 0)
dir.x = 0;
if (_transform.position.x >= gameManager.levelBounds.x && dir.x > 0)
dir.x = 0;
if (_transform.position.z <= -gameManager.levelBounds.y && dir.z < 0)
dir.z = 0;
if (_transform.position.z >= gameManager.levelBounds.y && dir.z > 0)
dir.z = 0;

// Apply movement
if (dir.magnitude > 0)
{
_transform.position += dir.normalized * (Time.deltaTime * gameManager.playerSpeed.value);
_transform.rotation = Quaternion.LookRotation(dir);
}
}

private void Shoot()
{
var directionToTarget = Vector3
.ProjectOnPlane(_closestTarget.position - _transform.position, Vector3.up).normalized;
var projectileGo = Instantiate(projectilePrefab, _transform.position,
Quaternion.LookRotation(directionToTarget));
var projectile = projectileGo.GetComponent<Projectile>();
projectile.damage = Mathf.RoundToInt(gameManager.pistolDamage.value);
projectile.knockBackIntensity = gameManager.pistolKnockBack.value;
projectile.pierceCount = (int)gameManager.pistolPierce.value;
_timeSinceLastFire = 0.0f;
}

private void ShootPredictive()
{
var projectileGo = Instantiate(projectilePrefab, _transform.position, Quaternion.identity);
var projectile = projectileGo.GetComponent<Projectile>();

// Calculate the time it would take for the projectile to reach the target's current position
var distanceToTarget = Vector3.Distance(_transform.position, _closestTarget.position);
var timeToTarget = distanceToTarget / projectile.projectileSpeed;

// Predict the target's position after the time it would take for the projectile to reach it
var enemy = _closestTarget.GetComponent<EnemyController>();
var enemyVelocity = _closestTarget.forward * enemy.moveSpeed;
var predictedTargetPosition = _closestTarget.position + enemyVelocity * timeToTarget;

// now get the distance to that position
distanceToTarget = Vector3.Distance(_transform.position, predictedTargetPosition);
timeToTarget = distanceToTarget / projectile.projectileSpeed;
predictedTargetPosition = _closestTarget.position + enemyVelocity * timeToTarget;

// iterate again
distanceToTarget = Vector3.Distance(_transform.position, predictedTargetPosition);
timeToTarget = distanceToTarget / projectile.projectileSpeed;
predictedTargetPosition = _closestTarget.position + enemyVelocity * timeToTarget;

// Aim the projectile towards the predicted position
var shootDirection = (predictedTargetPosition - _transform.position).normalized;

if (isDebugMode)
{
// draw a line from the player to the predicted position
Debug.DrawLine(_transform.position, predictedTargetPosition, Color.green,
1 / gameManager.pistolFireRate.value);

}

projectileGo.transform.forward = shootDirection;
projectile.damage = Mathf.RoundToInt(gameManager.pistolDamage.value);
projectile.knockBackIntensity = gameManager.pistolKnockBack.value;
projectile.pierceCount = (int)gameManager.pistolPierce.value;
_timeSinceLastFire = 0.0f;
}

private void LateUpdate()
{
localCanvas.rotation = _startRotation;
Camera position.
var cameraWishPosition = _transform.position + _cameraOffset;

// We want the same level bound logic for the camera, but it stops its position if the player is within 5m of the level bounds
if (_transform.position.x <= -gameManager.levelBounds.x + 5 ||
_transform.position.x >= gameManager.levelBounds.x - 5)
{
cameraWishPosition =
new Vector3(camera.transform.position.x, cameraWishPosition.y, cameraWishPosition.z);
}

if (_transform.position.z <= -gameManager.levelBounds.y + 5 ||
_transform.position.z >= gameManager.levelBounds.y - 5)
{
cameraWishPosition =
new Vector3(cameraWishPosition.x, cameraWishPosition.y, camera.transform.position.z);
}

camera.transform.position = cameraWishPosition;
SetUI();
}

private IEnumerator SwordAttack()
{
_isSwordAttacking = true;
var swordArc = gameManager.swordArc.value;
// Enable the sword gameobject.
swordPivot.gameObject.SetActive(true);
swordPivot.localScale = new Vector3(1f, 1f, gameManager.swordRange.value);

// Base rotation values.
var leftRotation = Quaternion.Euler(0, swordArc * -0.5f, 0);
var rightRotation = Quaternion.Euler(0, swordArc * 0.5f, 0);

// The start rotation needs to be directed to the closest target.
var directionToTarget = Vector3.ProjectOnPlane( _closestTarget.transform.position - transform.position, Vector3.up).normalized;
swordPivot.forward = directionToTarget;

// Determine the start and end rotation based on the current swing direction.
Quaternion startRotation, endRotation;
if (_isSwingingLeftToRight)
{
startRotation = Quaternion.LookRotation(directionToTarget) * leftRotation;
endRotation = Quaternion.LookRotation(directionToTarget) * rightRotation;
}
else
{
startRotation = Quaternion.LookRotation(directionToTarget) * rightRotation;
endRotation = Quaternion.LookRotation(directionToTarget) * leftRotation;
}

var total180Arcs = Mathf.FloorToInt(swordArc / 180f);
var swingTime = gameManager.swordRange.value * 0.08f;

if (total180Arcs > 0)
{
var lastStart = startRotation;
var directionSign = _isSwingingLeftToRight ? 1 : -1;
var lastEnd = startRotation * Quaternion.Euler(0, 179.9f * directionSign, 0);

for (var i = 0; i < total180Arcs; i++)
{
var t = 0.0f;
var swing = true;
while (swing)
{
t += Time.deltaTime;
swordPivot.rotation = Quaternion.Lerp(lastStart, lastEnd, t / swingTime);
yield return null;
if (!(t >= swingTime)) continue;
lastStart = swordPivot.rotation;
lastEnd = lastStart * Quaternion.Euler(0, 179.9f * directionSign, 0);
swing = false;

}
}
}
else
{
// Lerp the sword rotation from start to end over 0.5 seconds.
var t = 0.0f;

while (t < swingTime)
{
t += Time.deltaTime;
swordPivot.rotation = Quaternion.Lerp(startRotation, endRotation, t / swingTime);
yield return null;
}
}

_isSwordAttacking = false;

// Toggle the swing direction for the next attack.
_isSwingingLeftToRight = !_isSwingingLeftToRight;

// Disable the sword gameobject.
swordPivot.gameObject.SetActive(false);
}

public void TakeDamage(int damageAmount)
{
if (!_canTakeDamage) return;
if (_isDashing) return;
// Check if damage is dodged.
var hitChance = Random.Range(0, 100);

if (hitChance < gameManager.dodge.value)
{
if(_dodgeTextCoroutine != null) StopCoroutine(_dodgeTextCoroutine);
_dodgeTextCoroutine = StartCoroutine(ShowDodgeText());
return;
}

damageAmount -= (int)gameManager.block.value;
// We should never be invincible imo.
if (damageAmount <= 0)
{
damageAmount = 0;
}

if (_damageTextCoroutine != null)
{
StopCoroutine(_damageTextCoroutine);
}

_damageTextCoroutine = StartCoroutine(ShowDamageText(damageAmount));

AudioManager.instance.PlaySound(damageAmount > 0 ? takeDamageSound : blockSound);

AccountManager.instance.statistics.totalDamageTaken += damageAmount;

gameManager.playerCurrentHealth -= damageAmount;

if (gameManager.playerCurrentHealth <= 0)
{
gameManager.playerCurrentHealth = 0;

if ((int)gameManager.revives.value > 0)
{
reviveParticle.Play();
gameManager.revives.value--;

var enemyCount = enemyManager.enemies.Count;
var enemies = enemyManager.enemies.ToArray();
for (var i = 0; i < enemyCount - 1; i++)
{
var controller = enemies[i].GetComponent<EnemyController>();
if (controller != null)
{
controller.TakeDamage(9999);
}

}

gameManager.playerCurrentHealth = (int)gameManager.playerMaxHealth.value;
StartCoroutine(InvincibilityFrames());

return;
}

AccountManager.instance.statistics.totalDeaths++;
AudioManager.instance.PlaySound(deathSound);
gameManager.LoseGame();

List<Achievement> dieAchievements = AccountManager.instance.achievementSave.achievements
.Where(a => a.name == AchievementName.Die ||
a.name == AchievementName.Die50Times ||
a.name == AchievementName.Die100Times).ToList();
foreach (var a in dieAchievements)
{
if (a.isCompleted) return;
a.progress++;
if (a.progress >= a.goal)
{
a.isCompleted = true;
AccountManager.instance.AchievementUnlocked(a);
}
}
}
SetUI();
}

private IEnumerator InvincibilityFrames()
{
_canTakeDamage = false;
yield return new WaitForSeconds(0.5f);
_canTakeDamage = true;
}

private IEnumerator ShowDodgeText()
{
dodgeTextGo.SetActive(true);
var elapsedTime = 0f;
var t = dodgeTextGo.transform;

var startPosition = Vector3.right;
var targetPosition = Vector3.up + Vector3.right * 1f;

var startScale = Vector3.one * 0.25f;
var targetScale = Vector3.one;

while (elapsedTime < 0.6f)
{
elapsedTime += Time.deltaTime;
var normalizedTime = elapsedTime / 0.4f;
var inversedQuadraticTime = 1 - Mathf.Pow(1 - normalizedTime, 2);
t.position = Vector3.Lerp(startPosition + transform.position, targetPosition + transform.position, inversedQuadraticTime);
t.localScale = Vector3.Lerp(startScale, targetScale, inversedQuadraticTime);
yield return new WaitForEndOfFrame();
}
dodgeTextGo.SetActive(false);
yield return null;
}

private IEnumerator ShowDamageText(int damageAmount)
{
var dmgText = damageTextGo.GetComponent<TextMeshProUGUI>();
damageTextGo.SetActive(true);
dmgText.text = damageAmount.ToString();
var elapsedTime = 0f;
var t = damageTextGo.transform;

var startPosition = Vector3.right;
var targetPosition = Vector3.up + Vector3.right * 1f;

var startScale = Vector3.one * 0.25f;
var targetScale = Vector3.one;

while (elapsedTime < 0.6f)
{
elapsedTime += Time.deltaTime;
var normalizedTime = elapsedTime / 0.4f;
var quadraticTime = normalizedTime * normalizedTime;
var inversedQuadraticTime = 1 - Mathf.Pow(1 - normalizedTime, 2);
t.position = Vector3.Lerp(startPosition + transform.position, targetPosition + transform.position, inversedQuadraticTime);
t.localScale = Vector3.Lerp(startScale, targetScale, inversedQuadraticTime);
yield return new WaitForEndOfFrame();
}
damageTextGo.SetActive(false);
yield return null;
}

public void OnTriggerEnter(Collider other)
{

if(other.CompareTag("Spawn Indicator"))
{
Destroy(other.gameObject);
}

if (other.CompareTag("Health Pack"))
{
AudioManager.instance.PlaySound(healthPackSound);
var healthGained = (int)Mathf.Clamp( (GameManager.instance.playerCurrentHealth + GameManager.instance.playerMaxHealth.value * 0.1f + 1),
0f,
GameManager.instance.playerMaxHealth.value);

AccountManager.instance.statistics.totalDamageHealed += healthGained;
GameManager.instance.playerCurrentHealth = healthGained;

Destroy(other.gameObject);
}
}

private void SetUI()
{
healthText.text = $"{gameManager.playerCurrentHealth}/{(int)gameManager.playerMaxHealth.value}";
healthBar.fillAmount = (float)gameManager.playerCurrentHealth / (float)gameManager.playerMaxHealth.value;
}

public void ResetPlayer()
{
transform.SetPositionAndRotation(Vector3.up, Quaternion.identity);
camera.transform.position = _transform.position + _cameraOffset;
SetUI();
}
}

--

--