A mini space game made applying all SOLID Principles to serve as a reference of good code architecture.
- 🧊 Single Responsibility Principle
- A class should have only one responsibility.
- 🚪 Open-Closed Principle
- A software module should be open for extension but closed for modification.
- 🦆 Liskov Substitution Principle
- Derived classes must be substitutable for their base classes.
- 🤼 Interface Segregation Principle
- Clients should not be forced to depend upon the interfaces that they do not use.
↕️ Dependency Inversion Principle- Program to an interface, not to an implementation.
A class should have only one responsibility.
❌ Wrong Way
[RequireComponent(typeof(Collider2D))]
[RequireComponent(typeof(SpriteRenderer))]
public class Player : MonoBehaviour
{
[SerializeField] private float _moveSpeed = 25f;
[SerializeField] private int _maxHealth = 100;
[SerializeField] private bool _isInvulnerable;
[SerializeField] private Sprite _idleSprite;
[SerializeField] private Sprite _movingUpSprite;
[SerializeField] private Sprite _movingDownSprite;
[SerializeField] private Transform _projectileSpawnPoint;
[SerializeField] private GameObject _projectilePrefab;
[SerializeField] private GameObject _deathParticlesPrefab;
private SpriteRenderer _spriteRenderer;
private Vector3 _initialPosition;
private const float _timeToRespawn = 2f;
private int _health;
private void Start()
{
_spriteRenderer = GetComponent<SpriteRenderer>();
_initialPosition = transform.position;
_health = _maxHealth;
}
private void Update()
{
if (Input.GetButtonDown("Submit"))
{
ShootProjectile();
}
var vertical = Input.GetAxis("Vertical");
transform.position += Vector3.up * vertical * _moveSpeed * Time.deltaTime;
if (vertical == 0)
{
_spriteRenderer.sprite = _idleSprite;
}
else
{
_spriteRenderer.sprite = vertical > 0 ? _movingUpSprite : _movingDownSprite;
}
}
private void OnCollisionEnter2D(Collision2D collision)
{
int damageAmount = 1;
if (collision.collider.TryGetComponent<Asteroid>(out var asteroid))
{
TakeDamage(damageAmount);
}
else if (collision.collider.TryGetComponent<Enemy>(out var enemy))
{
TakeDamage(damageAmount * 5);
}
else if (collision.collider.TryGetComponent<Npc>(out var npc))
{
TakeDamage(0);
}
}
private void ShootProjectile()
{
var spawnedProjectile = Instantiate(_projectilePrefab, _projectileSpawnPoint.position, _projectileSpawnPoint.rotation);
spawnedProjectile.transform.position = transform.position;
}
private void TakeDamage(int damage)
{
if (!_isInvulnerable)
{
_health -= damage;
if (_health <= 0)
{
StartCoroutine(Respawn());
}
}
}
private IEnumerator Respawn()
{
_isInvulnerable = true;
_spriteRenderer.enabled = false;
Instantiate(_deathParticlesPrefab, transform.position, Quaternion.identity);
yield return new WaitForSeconds(_timeToRespawn);
transform.position = _initialPosition;
_spriteRenderer.enabled = true;
_isInvulnerable = false;
}
}
✔️ Right Way
[RequireComponent(typeof(PlayerHealth))]
[RequireComponent(typeof(PlayerInput))]
[RequireComponent(typeof(SpriteRenderer))]
public class PlayerDrawer : MonoBehaviour
{
[SerializeField] private float _moveSpeed = 25f;
[SerializeField] private Sprite _idleSprite;
[SerializeField] private Sprite _movingUpSprite;
[SerializeField] private Sprite _movingDownSprite;
private PlayerInput _playerInput;
private SpriteRenderer _spriteRenderer;
private Vector3 _initialPosition;
private const float _timeToMakePlayerVisibleAgain = 2f;
private void Awake()
{
GetComponent<PlayerHealth>().OnPlayerRespawn += RespawnPlayer;
_playerInput = GetComponent<PlayerInput>();
_spriteRenderer = GetComponent<SpriteRenderer>();
_initialPosition = transform.position;
}
private void Update()
{
transform.position += Vector3.up * _playerInput.Vertical * _moveSpeed * Time.deltaTime;
if (_playerInput.Vertical == 0)
{
_spriteRenderer.sprite = _idleSprite;
}
else
{
_spriteRenderer.sprite = _playerInput.Vertical > 0 ? _movingUpSprite : _movingDownSprite;
}
}
private void RespawnPlayer()
{
StartCoroutine(Respawn(_timeToMakePlayerVisibleAgain));
}
private IEnumerator Respawn(float delayInSeconds)
{
_spriteRenderer.enabled = false;
yield return new WaitForSeconds(delayInSeconds);
transform.position = _initialPosition;
_spriteRenderer.enabled = true;
}
}
public class PlayerInput : MonoBehaviour
{
public float Vertical { get; private set; }
public bool ShootProjectile { get; private set; }
public event Action OnShootProjectile = delegate { };
private void Update()
{
Vertical = Input.GetAxis("Vertical");
ShootProjectile = Input.GetButtonDown("Submit");
if (ShootProjectile)
{
OnShootProjectile();
}
}
}
[RequireComponent(typeof(Collider2D))]
public class PlayerHealth : MonoBehaviour
{
public event Action OnPlayerRespawn = delegate { };
[SerializeField] private int _maxHealth = 100;
[SerializeField] private bool _isInvulnerable;
private const float _delayToDisableInvulnerability = 3f;
private int _health;
private void Awake()
{
_health = _maxHealth;
}
private void OnCollisionEnter2D(Collision2D collision)
{
int damageAmount = 1;
if (collision.collider.TryGetComponent<Asteroid>(out var asteroid))
{
TakeDamage(damageAmount);
}
else if (collision.collider.TryGetComponent<Enemy>(out var enemy))
{
TakeDamage(damageAmount * 5);
}
else if (collision.collider.TryGetComponent<Npc>(out var npc))
{
TakeDamage(0);
}
}
private void TakeDamage(int damage)
{
if (!_isInvulnerable)
{
_health -= damage;
if (_health <= 0)
{
RespawnPlayer();
}
}
}
private void RespawnPlayer()
{
_isInvulnerable = true;
OnPlayerRespawn();
StartCoroutine(DisableInvulnerability(_delayToDisableInvulnerability));
}
private IEnumerator DisableInvulnerability(float delayInSeconds)
{
yield return new WaitForSeconds(delayInSeconds);
_isInvulnerable = false;
}
}
[RequireComponent(typeof(PlayerHealth))]
public class PlayerParticles : MonoBehaviour
{
[SerializeField]
private GameObject _deathParticlesPrefab;
private void Awake()
{
GetComponent<PlayerHealth>().OnPlayerRespawn += SpawnDeathParticles;
}
private void SpawnDeathParticles()
{
Instantiate(_deathParticlesPrefab, transform.position, Quaternion.identity);
}
}
[RequireComponent(typeof(PlayerInput))]
public class ProjectileLauncher : MonoBehaviour
{
[SerializeField] private GameObject _projectilePrefab;
[SerializeField] private Transform _projectileSpawnPoint;
private void Awake()
{
GetComponent<PlayerInput>().OnShootProjectile += SpawnProjectile;
}
private void SpawnProjectile()
{
var spawnedProjectile = Instantiate(_projectilePrefab, _projectileSpawnPoint.position, _projectileSpawnPoint.rotation);
spawnedProjectile.transform.position = transform.position;
}
}
A software module (it can be a class or method) should be open for extension but closed for modification.
❌ Wrong Way
[RequireComponent(typeof(PlayerInput))]
public class Weapon : MonoBehaviour
{
[SerializeField] private float _fireWeaponRefreshRate = 1f;
[SerializeField] private GameObject _bulletPrefab;
[SerializeField] private GameObject _missilePrefab;
[SerializeField] private Transform _projectileSpawnPoint;
private float _nextFireTime;
private void Awake()
{
GetComponent<PlayerInput>().OnFireWeapon += FireWeapon;
}
private void FireWeapon()
{
if (!CanFire())
{
return;
}
_nextFireTime = Time.time + _fireWeaponRefreshRate;
if (_bulletPrefab != null)
{
var spawnedBullet = Instantiate(_bulletPrefab, _projectileSpawnPoint.position, _projectileSpawnPoint.rotation);
spawnedBullet.transform.position = transform.position;
}
else if (_missilePrefab != null)
{
var spawnedMissile = Instantiate(_missilePrefab, _projectileSpawnPoint.position, _projectileSpawnPoint.rotation);
spawnedMissile.transform.position = transform.position;
}
// the list goes on...
}
private bool CanFire()
{
return Time.time >= _nextFireTime;
}
}
✔️ Right Way
[RequireComponent(typeof(ILauncher))]
[RequireComponent(typeof(PlayerInput))]
public class Weapon : MonoBehaviour
{
public Transform WeaponMountPoint => _weaponMountPoint;
[SerializeField] private float _fireWeaponRefreshRate = 0.25f;
[SerializeField] private Transform _weaponMountPoint;
private ILauncher _launcher;
private float _nextFireTime;
private void Awake()
{
_launcher = GetComponent<ILauncher>();
GetComponent<PlayerInput>().OnFireWeapon += FireWeapon;
}
private void FireWeapon()
{
if (!CanFire())
{
return;
}
_nextFireTime = Time.time + _fireWeaponRefreshRate;
_launcher.Launch(this);
}
private bool CanFire()
{
return Time.time >= _nextFireTime;
}
}
public interface ILauncher
{
void Launch(Weapon weapon);
}
public class BulletLauncher : MonoBehaviour, ILauncher
{
[SerializeField]
private Bullet _bulletPrefab;
public void Launch(Weapon weapon)
{
var spawnedBullet = Instantiate(_bulletPrefab);
spawnedBullet.Launch(weapon.WeaponMountPoint);
}
}
public class MissileLauncher : MonoBehaviour, ILauncher
{
[SerializeField] private Missile _missilePrefab;
[SerializeField] private float _missileSelfDestructTimer = 5f;
public void Launch(Weapon weapon)
{
var target = FindObjectOfType<Asteroid>();
var spawnedMissile = Instantiate(_missilePrefab);
spawnedMissile.SetTarget(weapon.WeaponMountPoint, target.transform);
StartCoroutine(spawnedMissile.SelfDestructAfterDelay(_missileSelfDestructTimer));
}
}
Derived classes must be substitutable for their base classes.
❌ Wrong Way
[RequireComponent(typeof(Collider2D))]
public class PlayerHealth : MonoBehaviour
{
private void OnCollisionEnter2D(Collision2D collision)
{
int damageAmount = 1;
if (collision.collider.TryGetComponent<Asteroid>(out var asteroid))
{
TakeDamage(damageAmount);
}
else if (collision.collider.TryGetComponent<Enemy>(out var enemy))
{
TakeDamage(damageAmount * 5);
}
// the list goes on...
}
}
✔️ Right Way
[RequireComponent(typeof(Collider2D))]
public class PlayerHealth : MonoBehaviour
{
private void OnCollisionEnter2D(Collision2D collision)
{
if (collision.collider.TryGetComponent<LivingEntity>(out var livingEntity))
{
TakeDamage(livingEntity.Damage);
}
}
}
public abstract class LivingEntity : MonoBehaviour
{
public abstract int Damage { get; }
[SerializeField]
protected int _maxHealth = 100;
protected int _health;
private void Awake()
{
_health = _maxHealth;
}
public virtual void TakeDamage(int damage)
{
_health -= damage;
}
}
public class Asteroid : LivingEntity
{
public override int Damage => 200;
public override void TakeDamage(int damage)
{
base.TakeDamage(damage);
if (_health <= 0)
{
var spawnedAsteroidPiece = Instantiate(_asteroidPiecePrefab);
spawnedAsteroidPiece.transform.position = Transform.position;
Destroy(gameObject);
}
}
}
public class Enemy : LivingEntity
{
public override int Damage => 100;
public override void TakeDamage(int damage)
{
base.TakeDamage(damage);
if (_health <= 0)
{
Destroy(gameObject);
}
}
}
Clients should not be forced to depend upon the interfaces that they do not use.
❌ Wrong Way
public interface IEntity
{
GameObject DeathParticlesPrefab { get; }
Sprite IdleSprite { get; }
Sprite MovingUpSprite { get; }
Sprite MovingDownSprite { get; }
float MoveSpeed { get; }
int Health { get; }
int MaxHealth { get; }
int Damage { get; }
void SpawnDeathParticles();
void TakeDamage(int damage);
void LaunchWeapon(Weapon weapon);
void LaunchProjectile(Transform mountPoint);
}
public class Asteroid : IEntity
{
// implement all interface members
}
public class BulletLauncher : IEntity
{
// implement all interface members
}
public class EnemyShip : IEntity
{
// implement all interface members
}
public class Missile : IEntity
{
// implement all interface members
}
✔️ Right Way
public interface IMovingEntity
{
GameObject DeathParticlesPrefab { get; }
float MoveSpeed { get; }
int Damage { get; }
void SpawnDeathParticles();
}
public interface IAnimatedShip
{
Sprite IdleSprite { get; }
Sprite MovingUpSprite { get; }
Sprite MovingDownSprite { get; }
}
public interface IHaveHealth
{
int Health { get; }
int MaxHealth { get; }
void TakeDamage(int damage);
}
public interface ILauncher
{
void Launch(Weapon weapon);
}
public interface IProjectile
{
void Launch(Transform mountPoint);
}
public class Asteroid : IMovingEntity, IHaveHealth
{
// implement only needed interfaces
}
public class BulletLauncher : ILauncher
{
// implement only needed interfaces
}
public class EnemyShip : IMovingEntity, IAnimatedShip, IHaveHealth
{
// implement only needed interfaces
}
public class Missile : IMovingEntity, IProjectile
{
// implement only needed interfaces
}
Program to an interface, not to an implementation.
❌ Wrong Way
[RequireComponent(typeof(UserInput))]
public class ShipInput : MonoBehaviour
{
public float Vertical { get; private set; }
public bool ShootProjectile { get; private set; }
public event Action OnFireWeapon = delegate { };
private UserInput _userInput;
private void Awake()
{
_userInput = GetComponent<UserInput>();
}
private void Update()
{
Vertical = _userInput.Vertical;
ShootProjectile = _userInput.ShootProjectile;
if (ShootProjectile)
{
OnFireWeapon();
}
}
}
✔️ Right Way
[RequireComponent(typeof(UserInput))]
public class PlayerInput : MonoBehaviour
{
public IInputService Input { get; private set; }
public event Action OnFireWeapon = delegate { };
[SerializeField]
private PlayerSettings _playerSettings;
private void Awake()
{
Input = _playerSettings.UseBot ? new BotInput() as IInputService: new UserInput();
}
private void Update()
{
Input.ReadInput();
if (Input.ShootProjectile)
{
OnFireWeapon();
}
}
}
public interface IInputService
{
float Vertical { get; }
bool ShootProjectile { get; }
void ReadInput();
}
public class UserInput : IInputService
{
public float Vertical { get; private set; }
public bool ShootProjectile { get; private set; }
public void ReadInput()
{
Vertical = Input.GetAxis("Vertical");
ShootProjectile = Input.GetButtonDown("Submit");
}
}
public class BotInput : IInputService
{
public float Vertical { get; private set; }
public bool ShootProjectile { get; private set; }
public void ReadInput()
{
Vertical = Random.Range(-1f, 1f);
ShootProjectile = Convert.ToBoolean(Random.Range(0, 1));
}
}