Game architecture on Unity without Zenject. Part 1


Article author: Igor Gulkin

Senior Unity Developer

Hi all!

My name is Igor Gulkin and I am a Unity developer. Over my 5 years, I have accumulated a lot of experience, so in this article I would like to share the principles and approaches with which you can implement the architecture of the game simply and flexibly without a framework. The purpose of the report is not just to give a ready-made solution, but to show the train of thought, how it is built. Well let’s go 🙂

Example

Let’s say we’re making a game where we control a dice with the keyboard. There are GameObjects on the stage:

  1. Player – the cube that the player controls.

  2. KeyboardInput – user input from the keyboard.

  3. MoveController – connects the user input and calls the cube Player.Move().

Here are the initial scripts for these classes:

Cube script:

public sealed class Player : MonoBehaviour
    {
        [SerializeField]
        private float speed = 2.0f;
    
        public void Move(Vector3 direction)
        {
            this.transform.position += direction * this.speed * Time.deltaTime;
        }
    }

User input script:

public sealed class KeyboardInput : MonoBehaviour
    {
        public event Action<Vector3> OnMove;

        private void Update()
        {
            this.HandleKeyboard();
        }

        private void HandleKeyboard()
        {
            if (Input.GetKey(KeyCode.UpArrow))
            {
                this.Move(Vector3.forward);
            }
            else if (Input.GetKey(KeyCode.DownArrow))
            {
                this.Move(Vector3.back);
            }
            else if (Input.GetKey(KeyCode.LeftArrow))
            {
                this.Move(Vector3.left);
            }
            else if (Input.GetKey(KeyCode.RightArrow))
            {
                this.Move(Vector3.right);
            }
        }

        private void Move(Vector3 direction)
        {
            this.OnMove?.Invoke(direction);
        }
    }

Move controller script:

public sealed class MoveController : MonoBehaviour
    {
        [SerializeField]
        private KeyboardInput input;

        [SerializeField]
        private Player player;

        private void OnEnable()
        {
            this.input.OnMove += this.OnMove;
        }

        private void OnDisable()
        {
            this.input.OnMove -= this.OnMove;
        }

        private void OnMove(Vector3 direction)
        {
            this.player.Move(direction);
        }
    }

The game works, but there are several architectural flaws:

  1. There is no entry point to the game and therefore no end.

  2. All dependencies on classes are put down manually through the inspector.

  3. All game logic is tied to MonoBehaviour.

  4. There is no game initialization order.

Let’s improve our architecture one by one.

Game state

We all know that a game is a process that has states. There is a state of loading the game, start, pause and end. In almost all games, it is necessary to make this state manageable. So it would be great if KeyboardInput and MoveController will be enabled on the game start event, not on startup PlayMode in Unity.

Then class design KeyboardInput will look like this:

public sealed class KeyboardInput : MonoBehaviour, 
        IStartGameListener,
        IFinishGameListener
    {
        public event Action<Vector3> OnMove;

        private bool isActive;

        void IStartGameListener.OnStartGame()
        {
            this.isActive = true;
        }

        void IFinishGameListener.OnFinishGame()
        {
            this.isActive = false;
        }
        
        private void Update()
        {
            if (this.isActive)
            {
                this.HandleKeyboard();
            }
        }

        //TODO: Rest code…
    }

A class MoveController will look like this:

public sealed class MoveController : MonoBehaviour, 
        IStartGameListener,
        IFinishGameListener
    {
        [SerializeField]
        private KeyboardInput input;

        [SerializeField]
        private Player player;

        void IStartGameListener.OnStartGame()
        {
            this.input.OnMove += this.OnMove;
        }

        void IFinishGameListener.OnFinishGame()
        {
            this.input.OnMove -= this.OnMove;
        }

        private void OnMove(Vector3 direction)
        {
            this.player.Move(direction);
        }
    }

Here we see that KeyboardInput And MoveController implement interfaces IStartGameListener And IFinishGameListener. Through these interfaces, system components receive signals about changes in the state of the game. Here you can immediately screw two more interfaces: IPauseGameListener, IResumeGameListener. They indicate when the game is paused and vice versa. Below is the code for all 4 interfaces:

public interface IStartGameListener
    {
        void OnStartGame();
    }

    public interface IPauseGameListener
    {
        void OnPauseGame();
    }

    public interface IResumeGameListener
    {
        void OnResumeGame();
    }

    public interface IFinishGameListener
    {
        void OnFinishGame();
    }

Thus, using the principle Interface Segregation components will only handle the game states they implement

Now someone has to tell the interfaces when the game state changes. Here we can turn to the pattern Observer and implement a receiver class that will receive signals about a change in the game phase. The structure of the receiver will be as follows:

public sealed class GameObservable : MonoBehaviour
    {
        private readonly List<object> listeners = new();

        [ContextMenu("Start Game")]
        public void StartGame()
        {
            foreach (var listener in this.listeners)
            {
                if (listener is IStartGameListener startListener)
                {
                    startListener.OnStartGame();
                }
            }
        }

        [ContextMenu("Pause Game")]
        public void PauseGame()
        {
            foreach (var listener in this.listeners)
            {
                if (listener is IPauseGameListener pauseListener)
                {
                    pauseListener.OnPauseGame();
                }
            }
        }

        [ContextMenu("Resume Game")]
        public void ResumeGame()
        {
            foreach (var listener in this.listeners)
            {
                if (listener is IResumeGameListener resumeListener)
                {
                    resumeListener.OnResumeGame();
                }
            }
        }

        [ContextMenu("Finish Game")]
        public void FinishGame()
        {
            foreach (var listener in this.listeners)
            {
                if (listener is IFinishGameListener finishListener)
                {
                    finishListener.OnFinishGame();
                }
            }
        }

        public void AddListener(object listener)
        {
            this.listeners.Add(listener);
        }

        public void RemoveListener(object listener)
        {
            this.listeners.Remove(listener);
        }
    }

Great! Let’s now add the script GameObservable to the stage:

If you click on the “three dots” next to this script, we can see that this script can call methods to start, pause and end the game.

By clicking on the magic “Play” button in Unity, we see that the movement of the cube by pressing the keyboard does not work. Why, you ask? Yes, because the KeyboardInput and MoveController components are not connected to the monobeh GameObservable as observers.

Therefore, we need a class that will register KeyboardInput And MoveController to the receiver GameObservable. Let’s call this class GameObservableInstaller.

public sealed class GameObservableInstaller : MonoBehaviour
    {
        [SerializeField]
        private GameObservable gameObservable;
            
        [SerializeField]
        private MonoBehaviour[] gameListeners;

        private void Awake()
        {
            foreach (var listener in this.gameListeners)
            {
                this.gameObservable.AddListener(listener);
            }
        }
    }

Everything is very simple here: the installer contains a link to the receiver and an array with other monobehs that implement the game state interfaces. In method Awake() register all listeners to the receiver.

Then I add the script GameObservableInstaller to the stage and connect him to the liseners:

Now we need to check that everything works!

  1. I’m running PlayMode in Unity.

  2. I call in the context menu of the receiver GameObservable method StartGame.

  3. I press the keyboard and see that “the cube has gone”.

  4. Voila, everything works!

As an added bonus, we can check that if we call the method GameObservable.FinishGame()That KeyboardInput And MoveController will stop working.

Everything is fine, but there are a couple of nuances:

  • There is no way to know the current state of the game.

  • You can call game events in any order (such as “pause” after “end”, etc.).

Let’s modify our receiver:

public enum GameState
    {
        OFF = 0,
        PLAY = 1,
        PAUSE = 2,
        FINISH = 3,
    }

public sealed class GameMachine : MonoBehaviour
    {
        public GameState GameState
        {
            get { return this.gameState; }
        }

        private readonly List<object> listeners = new();

        private GameState gameState = GameState.OFF;
        
        [ContextMenu("Start Game")]
        public void StartGame()
        {
            if (this.gameState != GameState.OFF)
            {
                Debug.LogWarning($"You can start game only from {GameState.OFF} state!");
                return;
            }

            this.gameState = GameState.PLAY;

            foreach (var listener in this.listeners)
            {
                if (listener is IStartGameListener startListener)
                {
                    startListener.OnStartGame();
                }
            }
        }

        [ContextMenu("Pause Game")]
        public void PauseGame()
        {
            if (this.gameState != GameState.PLAY)
            {
                Debug.LogWarning($"You can pause game only from {GameState.PLAY} state!");
                return;
            }

            this.gameState = GameState.PAUSE;

            foreach (var listener in this.listeners)
            {
                if (listener is IPauseGameListener pauseListener)
                {
                    pauseListener.OnPauseGame();
                }
            }
        }

        [ContextMenu("Resume Game")]
        public void ResumeGame()
        {
            if (this.gameState != GameState.PAUSE)
            {
                Debug.LogWarning($"You can resume game only from {GameState.PAUSE} state!");
                return;
            }

            this.gameState = GameState.PLAY;

            foreach (var listener in this.listeners)
            {
                if (listener is IResumeGameListener resumeListener)
                {
                    resumeListener.OnResumeGame();
                }
            }
        }

        [ContextMenu("Finish Game")]
        public void FinishGame()
        {
            if (this.gameState != GameState.PLAY)
            {
                Debug.LogWarning($"You can finish game only from {GameState.PLAY} state!");
                return;
            }

            this.gameState = GameState.FINISH;

            foreach (var listener in this.listeners)
            {
                if (listener is IFinishGameListener finishListener)
                {
                    finishListener.OnFinishGame();
                }
            }
        }

        public void AddListener(object listener)
        {
            this.listeners.Add(listener);
        }

        public void RemoveListener(object listener)
        {
            this.listeners.Remove(listener);
        }
    }

First of all, I think you noticed that I added enum GameState in which, an enumeration of the possible states of the game is indicated.

Secondly, our wonderful script GameObservable renamed to GameMachine. This is due to the fact that our current class is no longer dispatching events, but switching the state of the game as a whole.

Thus, we have a mechanism by which we can manage the state of the game and notify the system components about it.

This concludes the first part of the article, to be continued 🙂

In conclusion, I invite you to free lessonwhere we will study the Model-View-Adapter pattern in a simplified version without user input using the player’s coins widget as an example.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *