Unity — Complete BattleRoyale Mobile Game: Implementation of Zenject, UniRx, UniTask, Async Addressables, Scriptable Objects, Unit Testing, and More.

Source code of the project: GitHub

Caner Nurdag
10 min readMay 4, 2024

Gameplay

Intro

As a professional Unity user, I am always willing to learn and try out new game-development techniques. I think creating an actual product is the best approach to accomplish this.

Therefore, I have developed a complete game that resembles Brawl Stars, a huge hit from Supercell. For this article and the ones that follow, I will be using this game to implement and experiment with third-party libraries, frameworks, etc.

In this article, I focus mostly on Zenject, UniRx, UniTask, Addressables, and Unit testing.

[CS 1.6 mode: on] Go, Go, Go!

Architect

Architect — Layers

1. Object Communication Layer: Instead of “Service Locator System”, I use “Zenject” in this project. (In the following chapters, I will be explaining Zenject in detail.)

2. Manager Layer: They are responsible for systems that maintain the application.

3. Controller Layer: They are mostly responsible for controlling actor objects inside a logic.

4. Actors Layer ( similar to Unreal Engine approach): Characters, UI objects, props, etc.

Architect — Programming Paradigm

The main paradigm is object-oriented programming.
(Inheritance, encapsulation, polymorphism, etc).

In addition to OOP, rarely the reactive programming paradigm is used.

Architect — Design Patterns

On top of Unity’s architect, the patterns below are used in the project.

Observer Pattern: Example: Used for sending signals to observer objects when a user joins a game session or leaves a game session.

Factory Pattern: Example: Used to create Characters via Zenject Factory. The major benefit of using the Zenject factory is that all dependencies are injected into instantiated objects.

Object Pool Pattern: Example: Used for bullets, of which I need many concurrently and repeatedly.

State Pattern: Example: Used for character attack state machine and character movement state machines.

Architect — Object Dependencies

There are 2 types of dependencies:

1. Direct References: Which we use as drag&drop in Unity editor. I think everyone knows this.

2. Injected Dependencies: Which Zenject sets all the required dependencies.

Architect —Data

1. Predefined data is mostly stored in Scriptable Objects. However, sometimes I prefer to store on prefabs, which is quicker.

2. Save data is a single serializable object. Loading and saving processes are done through SaveSystem.

namespace Assets.Scripts.Runtime.Systems.SaveSystem
{
[Serializable]
public class SaveState
{
#region SAVE DATA
public string BundleVersion;
#endregion

#region USER DATA
public int UserId;
public string UserName;
public int TotalMoneyAmount;

public CharacterVisual.Type SelectedCharacterVisualType;
public Weapon.Type SelectedWeaponType;
public List<SaveDataType_CharacterVisual> SaveDataType_CharacterVisuals;
public List<SaveDataType_WeaponVisual> SaveDataType_Weapons;

public int ExperienceLevel;
public int ExperienceAmount;
#endregion

#region PREF DATA
public float SFXLevel;
public float MusicLevel;
public bool IsVibrationOn;
public bool IsNoAdsOn;
#endregion
}
}

Implementations

#Zenject

Ref

Zenject defines itself as below.

Zenject is a lightweight highly performant dependency injection framework built specifically to target Unity 3D.

Although Zenject seems to be just a DI framework, I view Zenject as a more comprehensive solution. The reason for that is that Zenject has integrated:

  • Memory Pool (Object Pool): Due to its very time-consuming and complex structure, I find my event system more practical.
  • Factory: Well designed.
  • Signal System (Event System): Well designed. It is very similar to other event systems.

You may find implementation for all of them in the GitHub repository.

-How Zenject Works-

In the runtime environment, Zenject Context and Zenject Kernel classes initialize themselves before all other classes to guarantee the correct script execution order. This is the same in my Service Locator approach. No surprise :)

After that, Zenject instantiates the “ProjectContext” from the Resources folder as “DontDestroyOnLoad”.

The “ProjectContext” mainly includes 2 important things: Zenject installers and my managers.

For the logic of how dependencies are supposed to be injected, we use Zenject’s “Installers” which are handled within “Project Context” or “Scene Context”.

PS: Scene Context is scene-dependent whereas Project Context is always live in “DontDestroyOnLoad”.

Let me give some injection examples quickly.

Case 1: When the ManagerScene requires the ManagerGame object.

1. We bind ManagerGame in a monoinstaller script by overriding the InstallBindings() method.

public class Project_Manager_Installer : MonoInstaller
{

public override void InstallBindings()
{
Container.Bind<ManagerGame>().FromComponentInHierarchy().AsSingle();
}
}

2. Similar to C# construct method, we need to create a method with [Inject] attribute.

public class ManagerScene : MonoBehaviour
{
#region REFERENCES
private ManagerGame _managerGame;
#endregion

[Inject]
public void Construct(ManagerUi managerUi)
{
_managerGame = managerGame;
}
}

Case 2: The StartCountDown() method of the ControllerMatch class needs to subscribe to the SignalGameStateChange signal.

1- A “SignalGameStateChanged” is declared in the InstallBindings() method.

2- The StartCountDown() method subscribes to “SignalGameStateChanged” by using the Container’s BindSignal() method.

public override void InstallBindings()
{
SignalBusInstaller.Install(Container);

//SIGNAL DECLARATION
Container.DeclareSignal<SignalGameStateChanged>();

//SIGNAL BINDINGS
Container.BindSignal<SignalGameStateChanged>()
.ToMethod<ControllerMatch>(x => x.StartCountdown)
.FromResolve();
}

3- Signal firing example.

public void SetCurrentGameStateType(GameStateType gameStateType)
{
_currentGameStateType = gameStateType;
_signalBus.Fire(new SignalGameStateChanged(gameStateType));

}

There are many ways to inject dependency in Zenject. Since there is amazing documentation, I will not explain each of them.

Please refer to the cheat sheet. https://github.com/modesttree/Zenject/blob/master/Documentation/CheatSheet.md

-PROs Zenject-

  • Separation of dependency logic from components
  • Avoid code repeating for some objects. => Case: Different objects call GetCompnent/GetComponentInChildren etc for the same object.
  • Amazing documentation
  • Avoiding null references because of script execution order. (Sometimes we can get null references in builds where everything is fine in Editor’s Play mode)

-CONs Zenject-

  • Extra tons of classes. Sometimes I feel Zenject is an over-engineering :)
  • Async problems

#UniRx — Reactive Programming

Ref

Reactive programming is a declarative programming paradigm concerned with data streams and the propagation of change. (Ref)

On the other hand, UniRx defines itself as below.

UniRx (Reactive Extensions for Unity) is a reimplementation of the .NET Reactive Extensions.

From my perspective, Rx is working with data streams. And, the structure looks like Linq.

Check the example below.

1- The data stream defined as ObservableInputForPlayerMovement is created as IObservables<Vector2> in ManagerInput.

2- Data stream value logic is determined in Awake.

ManagerInput.cs 

#region Cache
public IObservable<Vector2> ObservableInputForPlayerMovement { get; private set; }
public IObservable<Vector2> ObservableInputForPlayerAttack { get; private set; }

public float InputCorrectionAccordingToCameraAngle { get; private set; }
#endregion


private void Awake()
{
ObservableInputForPlayerMovement = this.UpdateAsObservable()
.Select(_ => InputProviderForPlayerMovement.GetCurrentMobileInput() * InputCorrectionAccordingToCameraAngle);

ObservableInputForPlayerAttack = this.UpdateAsObservable()
.Select(_ => InputProviderForPlayerAttack.GetCurrentMobileInput() * InputCorrectionAccordingToCameraAngle);

}

3- In CharacterMovementPlayer.cs, character movement logic according to the data stream is determined.

CharacterMovementPlayer.cs

private void Start()
{
Movement_RX();
}

private void Movement_RX()
{
if (_managerInput == null) return;

_managerInput.ObservableInputForPlayerMovement
.Where(_ => _managerGame.CurrentGameStateType == GameStateType.GameStarted)
.Where(_ => _characterGameplay.IsAlive)
.Subscribe(input =>
{
//In order to satisfy abstract class parameter, we cast Vector2 to Vector3. Z Value is 0.
Vector3 inputV3 = (Vector3)input;

if (input == Vector2.zero)
{
if (IsMoving)
{
SetIsMoving(false);
_characterPlayerMovementStateMachine.SetCurrentState(new CharacterMovementIdleState(_characterPlayerMovementStateMachine));
}

}
else if (input != Vector2.zero)
{
if (!IsMoving)
{
SetIsMoving(true);
_characterPlayerMovementStateMachine.SetCurrentState(new CharacterMovementMoveState(_characterPlayerMovementStateMachine));
}
Move(inputV3);
}


if (!_managerInput.InputProviderForPlayerAttack.IsInputExist())
{
if (_characterWeaponAttack.GetActiveWeaponAttack().IsWeaponAttackFinished)
{
Rotate(inputV3);
}
}

});

_managerInput.ObservableInputForPlayerAttack
.Where(_ => _managerGame.CurrentGameStateType == GameStateType.GameStarted)
.Where(x => x != Vector2.zero)
.Where(_ => _characterGameplay.IsAlive)
.Subscribe(input =>
{
RotateForAttack(input);

});
}

-PROs UniRx-

  • Ready Unity-specific methods
  • Async ability
  • Linq style structure

-CONs UniRx-

  • Lack of documentation and tutorials
  • Painful debugging.

#UniTask

The coroutines are the first thing we consider when we need async operations in Unity. Nevertheless, the coroutines have lots of weaknesses.

So, UniTask is a great alternative to coroutines.

UniTask is a library that provides an efficient allocation free async/await integration for Unity.

Look at the example of an addressables async operation.

public async UniTask<GameObject> GetAndInstantiateGameObjectAsync(AssetReference assetReference)
{
var asyncOperationHandle = assetReference.InstantiateAsync().WithCancellation(this.GetCancellationTokenOnDestroy());
var result = await asyncOperationHandle;

return result;
}
ActiveWeaponGameObject = await _managerAddressables.GetAndInstantiateGameObjectAsync(weaponAssetReference);

UniTask vs Coroutine

  • Return Value: UniTask can have a return value whereas coroutine returns only IEnumerator.
  • Memory allocation: Coroutine is more expensive in terms of memory allocation. (I encountered this issue in one of the previous voxel games.)
  • Availability: Objects must be inherited from Monobehavior for coroutines.
  • Lifetime management: Coroutines automatically stop when the object is destroyed whereas UniTask needs a cancelation. “.WithCancellation(this.GetCancellationTokenOnDestroy())”
  • Handle Exception: Coroutines are not capable of handling exceptions.

A good article on UniTask vs Coroutine

#Addressables

In this article, we will cover why addressables are important in terms of dependency management and memory management. Content packing and bundles are not the subject of this article.

1- Memory Management

[SerializeField] is a very risky attribute. The reason is Unity loads every object you reference to the memory, even if they are not used. So, the solution is the addressables.

The addressables system give us an ability to load/unload objects on demand. The key is using addresses instead of references.

2- Dependency Management

The addressables system give us an ability to load/unload all dependencies of an object in the same time.

Check the example below.

[field: SerializeField] public AssetReferenceAudioClip AR_TriggerAudioClip { get; protected set; }
public AudioClip TriggerAudioClip { get; protected set; }
[field: SerializeField] public AssetReferenceAudioClip AR_HitAudioClip { get; protected set; }
public AudioClip HitAudioClip { get; protected set; }


protected virtual void Awake()
{
_ = SetTriggerAudioClip();
_ = SetHitAudioClip();

}

protected async UniTaskVoid SetTriggerAudioClip()
{
if (AR_TriggerAudioClip == null) return;
if(_managerAddressables == null) return;

TriggerAudioClip = await _managerAddressables.GetAudioClipAsync(AR_TriggerAudioClip);
}

protected async UniTaskVoid SetHitAudioClip()
{
if (AR_HitAudioClip == null) return;
if (_managerAddressables == null) return;

HitAudioClip = await _managerAddressables.GetAudioClipAsync(AR_HitAudioClip);
}
namespace Assets.Scripts.Runtime.Core.Managers.Manager_Addressables
{
public class ManagerAddressables : MonoBehaviour
{
#region OBJECT FUNCTIONS

public async UniTask<GameObject> GetAndInstantiateGameObjectAsync(AssetReference assetReference)
{
var asyncOperationHandle = assetReference.InstantiateAsync().WithCancellation(this.GetCancellationTokenOnDestroy());
var result = await asyncOperationHandle;

return result;
}

public async UniTask<AudioClip> GetAudioClipAsync(AssetReferenceAudioClip assetReferenceAudioClip)
{
var asyncOperationHandle = assetReferenceAudioClip.LoadAssetAsync<AudioClip>().WithCancellation(this.GetCancellationTokenOnDestroy());
var result = await asyncOperationHandle;

return result;
}

public void ReleaseAndDestroy(GameObject addressedGameObject)
{
if (!Addressables.ReleaseInstance(addressedGameObject))
Destroy(addressedGameObject);
}

#endregion
}

[Serializable]
public class AssetReferenceAudioClip : AssetReferenceT<AudioClip>
{
public AssetReferenceAudioClip(string guid) : base(guid)
{
}
}
}

-Async Addressables & Zenject Conflict-

Case: ActiveWeapon variable, which Zenject needs to inject into some other objects, has to be ready in the first frame due to Zenject’s nature. On the other hand, an async addressables is needed for initalizing ActiveWeapon. This was the problem I encountered.

Therefore, I wrote an adapter class AddressableAdapterWeapon for Zenject to inject dependency to CharacterGameplay.

public class CharacterGameplay : Character, ICharacterGameplay
{
protected IEnumerator InitCharacterWeapon()
{
var weaponType = User.Userdata.WeaponType;
CharacterWeapon.SetActiveWeapon(weaponType);
while (CharacterWeapon.GetActiveWeapon() == null)
{
yield return null;
}

var weaponAttack = CharacterWeapon.GetActiveWeapon().GetComponent<WeaponAttack>();
weaponAttack.SetWeaponType(weaponType);
CharacterWeaponAttack.SetActiveWeaponAttack(weaponAttack);
CharacterWeaponAttack.InitChararcterWeaponAttack();
}
}

public class AddressableAdapterWeapon : AddressableAdapter
{
public async void SetActiveWeapon(Weapon.Type weaponType, Action callback)
{
if (IsAsyncOn) return;
ReleaseInstances();

var weaponAssetReference = GetWeaponAssetReference(weaponType);
if (weaponAssetReference != null)
{
ActiveWeaponGameObject = await _managerAddressables.GetAndInstantiateGameObjectAsync(weaponAssetReference);
if (callback != null) callback();
}
}
}

By the way, as I observe in Blizzard’s Overwatch, a character’s abilities and behaviours are ready in the beginning of the first frame, even though async loading operations of materials, shaders, textures, sounds are not finished. This makes sense to me.

-PROs Addressables-

  • Excellent memory management

-CONs Addressables-

  • Extra works such as builds etc.

#Test

Unit tests and Integration tests are practical solutions that avoid potential future bugs. So, I highly recommend them.

Let’s see an example.

namespace Assets.Scripts.Runtime.Tests
{
public class TestPlayerMovement
{
[UnityTest]
public IEnumerator CharacterGameplayPlayerMovement_WhenUpInput_ExpectTransformPositionZIsHigher_Test()
{
//ARRANGE
GameObject playerGO = new GameObject("Player");
CharacterMovementPlayer characterMovementPlayer = playerGO.AddComponent<CharacterMovementPlayer>();

Rigidbody rigidbody = playerGO.AddComponent<Rigidbody>();
rigidbody.useGravity = false;

CapsuleCollider capsuleCollider = playerGO.AddComponent<CapsuleCollider>();

characterMovementPlayer.Test_SetRigidbody(rigidbody);
characterMovementPlayer.Test_SetMockCharacterControllerSettings();

//ACT
float inputDuration = 0.5f;
Vector3 upInput = new Vector3(0, 1, 0);
while (inputDuration > 0)
{
inputDuration -= Time.deltaTime;
characterMovementPlayer.Move(upInput);
yield return null;
}

yield return new WaitForSeconds(inputDuration);

//ASSERT
Assert.IsTrue(playerGO.transform.position.z > 0f);
}
}
}

Let’s see another example. In this case, we need a mock for ICharacter interface. NSubstitute is an excellent fixer.

namespace Assets.Scripts.Runtime.Tests
{
public class TestCharacterAttack
{
[Test]
public void CharacteAttackPlayer_WhenNoAmmo_ExpectAttackFails()
{
//ARRANGE
GameObject playerGO = new GameObject("Player");
CharacterAttackPlayer characterAttackPlayer = playerGO.AddComponent<CharacterAttackPlayer>();
characterAttackPlayer.TestSetAllAmmosEmpty();

ICharacter character = Substitute.For<ICharacter>();
characterAttackPlayer.TestSetCharacter(character);

CharacterPlayerAttackStateMachine characterPlayerAttackStateMachine = playerGO.AddComponent<CharacterPlayerAttackStateMachine>();
characterPlayerAttackStateMachine.SetCurrentState(new CharacterAttackIdleState(characterPlayerAttackStateMachine));

//ACT
var signalCharacterAttack = new SignalCharacterAttack(character, 0.1f);
characterAttackPlayer.SetAttackState(signalCharacterAttack);

//ASSERT
Assert.AreNotEqual(characterPlayerAttackStateMachine.CurrentState.GetType(), typeof(CharacterAttackAttackState));
}


}
}

PS: Before testing in Unity, we need to create assemble definitions for scripts we created. However, the 3rd party dev assets may not have assembly definitions. So, we need to create for them as well. Besides, assembly definitions do not respect “Editor” folders. So, do not forget to create assembly definitions for Editor classes.

-PROs Testing-

  • Efficient method prevents from bugs

-CONs Testing-

  • Required extra time

Extras

In the project, you may find the implementation of

  • Animation System: Locomotion, Animation Layers
  • Scriptable Objects: Data container
  • Cinemachine: State Driven Camera, Camera Shake, Ghost Object logic
  • Input System: Implementation of concurrent 2 joystick
  • UI System: Responsive Ui implementation through Layout Groups and Layout Element

References

Used 3rd party art assets:

--

--