Fighting Entropy in Unity - Input Locks

In this post, I will show you how to create an efficient Input lock service for Unity that follow SOLID principles. You can take a look at the example project. https://github.com/bartouv/InputLockExampleProject

Input locks are a crucial aspect of game development that allows you to control user input in various ways. For example, you might need to prevent a button from being clickable or block the use of a raycaster at certain points in the game. There are numerous elements that can receive input and may need to be locked in specific game flows.

Over the years i have encountered numerous input lock implementations that were prone to bugs and other issues. Here are some examples of the problems that these flawed.

Things we want to avoid:How we want it to work:
Race conditions when somewhere in the code a request to lock input is sent and in another, a request to unlock input is sent right after unlocking the first request by accident.We want each lock to only be opened by a specific key. If something is locked with two locks and a key is used to unlock one of the locks then the other lock will remain.
When something new is added to the scene it ignores the input lock which is running.We want new lockable elements to check on initialization if they need to be locked.
The code which request the input lock is not the same code which unlocks the code.We want the code which is responsible for locking input to also be responsible for unlocking it.
There is no way to lock only some of the things in the scene.We want the ability to define what the lock will be responsible for locking.
No way to debug if the input is locked.We want an easy way to debug what locks are currently locked and what those locks are locking.
The locking mechanism is implemented per lockable objectWe want the Input lock service to be the only code responsible for locking and unlocking inputs.

So let's together now build an input lock service that follows the SOLID principles.


The Lock

So let's start with the lock:

    public class InputLock
    {
        public readonly string Guid;
        public readonly InputLockTag[] InputLockTags;
        public readonly string LockOwner;

        public InputLock(InputLockTag[] inputLockTags,[CallerFilePath] string callerName = "")
        {
            Guid = System.Guid.NewGuid().ToString();
            InputLockTags = inputLockTags;
            LockOwner = Path.GetFileNameWithoutExtension(callerName);
        }

        public override string ToString()
        {   
            return string.Join( ",", InputLockTags);
        }
    }

We want each lock to be unique, and so on creation, we will give the lock a GUID (globally unique identifier). C# provides us with a simple way to do this, you can check out the documentation here. This insures that each lock we create will be easily identified and different from the rest.

We want to give the lock Tags which it will be responsible to lock. This way we can mark lockable objects with tags and decide which of these tags we want to lock. In the case where an object has two locking tags and two different locks are each locking a different tag, it will remain locked until both locks are unlocked.

We want to know which part of our code created the lock and for this C# also gives us a simple ability to do this, we will use the attribute [CallerFilePath] which allows us to obtain the path of the caller class in compile time. We will be able to use this info later to easily debug where the input lock came from. We will also use Path.GetFileNameWithoutExtension a functionality that C# provides us in order to take the file path we got from the caller and reduce it to only the class name which is all we really want.


Subscribing the lockable objects to the Input Lock Service

We want our input lock service to have a subscription functionality. That means that we want the lockable object to be responsible for subscribing to the service when it is created and unsubscribing when it is destroyed. Since the lockable objects will be a MonoBehaviour, and MonoBehaviours have unity event functions Awake and OnDestroy which are called during the creation and destruction of the object, we will let the lockable object decide when to subscribe and unsubscribe. This way we make sure that there will be no memory leaks.

So we will create the following interface for the input lock service. This interface will only be relevant for the lockable objects and not the code which will be responsible for creating the locks themselves. This follows the interface segregation principle of SOLID, as we do not force the lockable object to be dependent on functions it doesn’t need.

public interface IInputLockServiceSubscription
{
    void SubscribeLockable(BaseInputLockable inputLockable);
    void UnsubscribeLockable(BaseInputLockable inputLockable);
}

Another thing we want to do on subscription is look at all the locks that are locked and see if the lockable object we are subscribing should be locked and if so, lock it. This is what the Update Lockable function is doing. This will also be called on each new lock that is added and each time a lock will be unlocked.

public void SubscribeLockable(BaseInputLockable inputLockable)
{
    _inputLockSubscribers.Add(inputLockable);
    UpdateLockable(inputLockable);
}

public void UnsubscribeLockable(BaseInputLockable inputLockable)
{
    _inputLockSubscribers.Remove(inputLockable);
}

The lockable object

Now let's look at the lockable object:

    public abstract class BaseInputLockable : MonoBehaviour
    {
        [SerializeField] public List<InputLockTag> Tags;
        private IInputLockServiceSubscription _inputLockServiceSubscription;
        public bool IsLocked { get; private set; }

        [Inject]
        private void Inject(IInputLockServiceSubscription inputLockServiceSubscription)
        {
            _inputLockServiceSubscription = inputLockServiceSubscription;
        }
        protected virtual void Awake()
        {
            _inputLockServiceSubscription.SubscribeLockable(this);
        }

        public void Lock()
        {
            if (IsLocked) return;
            LockInternal();
            IsLocked = true;
        }

        public void Unlock()
        {
            if (!IsLocked) return;
            UnlockInternal();
            IsLocked = false;
        }

        private void OnDestroy()
        {
            _inputLockServiceSubscription.UnsubscribeLockable(this);
        }

        protected abstract void LockInternal();
        protected abstract void UnlockInternal();
    }

So let's look at the above code. The class has Tags, as serialized fields. The user can configure the specific lockable with whichever locking Tags are relevant. Notice that we are Injecting only the IInputLockServiceSubscription interface since the rest of the input lock service doesn’t interest us here. On creation of the object, the awake function is called and subscribes itself to the Input Lock Service. On destruction, the object unsubscribes itself from the service preventing memory leaks. This class will be the base class for all lockables and as such lock and unlock functions are generic all they do is all internal abstract functions, update the IsLocked state, and prevent the functions from being called if the current state is already applied.

let's look at two examples of implementing the base class:

bellow is an example of a button lockable

[RequireComponent(typeof(Button))]
public class ButtonInputLocker : BaseInputLockable
{
    private Button _button;

    protected override void Awake()
    {
        _button = GetComponent<Button>();
        base.Awake();
    }

    protected override void LockInternal()
    {
        _button.enabled = false;
    }

    protected override void UnlockInternal()
    {
        _button.enabled = true;
    }
}

And bellow here is an example of a raycaster inputlock

[RequireComponent(typeof(BaseRaycaster))]
public class RaycasterInputLocker : BaseInputLockable
{
    private BaseRaycaster _baseRaycaster;

    protected override void Awake()
    {
        _baseRaycaster = GetComponent<BaseRaycaster>();
        base.Awake();
    }

    protected override void LockInternal()
    {
        _baseRaycaster.enabled = false;
    }

    protected override void UnlockInternal()
    {
        _baseRaycaster.enabled = true;
    }
}

Note how simple it is to add new types of lockables with different functionality. The service itself is agnostic to the type of lockable, this follows the Open/Closed principle of solid, as the code is easily extendable without the need for modification.

The Input Lock Service

Let's look at the input lock service interface

    public interface IInputLockService
    {
        InputLock LockInput(InputLockTag[] inputLockTags);
        void UnlockInput(InputLock inputLock);
        bool IsTagLocked(InputLockTag tag);
        InputLock LockAllInputs();
        InputLock LockAllExcept(InputLockTag[] inputLockTagsExcept);
    }

let's look at the implementation of the input lock service

     public class InputLockService : IInputLockService, IInputLockServiceSubscription
    {
        private readonly Dictionary<InputLockTag, HashSet<string>> _locks = new Dictionary<InputLockTag, HashSet<string>>();
        private readonly Dictionary<string, InputLock> _idToInputLock = new Dictionary<string, InputLock>();
        private readonly HashSet<BaseInputLockable> _inputLockSubscribers = new HashSet<BaseInputLockable>();

        public InputLockService()
        {
            foreach (var tags in Enum.GetValues(typeof(InputLockTag)))
            {
                _locks.Add((InputLockTag)tags, new HashSet<string>());
            }
        }

        public List<InputLock> GetInputLocks()
        {
            return _idToInputLock.Values.ToList();
        }

        public void SubscribeLockable(BaseInputLockable inputLockable)
        {
            if(_inputLockSubscribers.Contains(inputLockable)) return;

            _inputLockSubscribers.Add(inputLockable);
            UpdateLockable(inputLockable);
        }

        public void UnsubscribeLockable(BaseInputLockable inputLockable)
        {
            if(!_inputLockSubscribers.Contains(inputLockable)) return;

            _inputLockSubscribers.Remove(inputLockable);
        }

        public bool IsTagLocked(InputLockTag tag)
        {
            try
            {
                return _locks[tag].Count > 0;
            }
            catch (IndexOutOfRangeException e)
            {
                Debug.Log($"IsTagLocked {tag.ToString()} is out of bounds");
            }

            return false;
        }

        public InputLock LockAllInputs()
        {
            var inputLockTags = Enum.GetValues(typeof(InputLockTag)).OfType<InputLockTag>().ToList();
            return LockInput(inputLockTags.ToArray());;
        }

        public InputLock LockAllExcept(InputLockTag[] inputLockTagsExcept)
        {
            var inputLockTags = Enum.GetValues(typeof(InputLockTag)).OfType<InputLockTag>().ToList();
            foreach (var exceptionTag in inputLockTagsExcept)
            {
                inputLockTags.Remove(exceptionTag);
            }
            return LockInput(inputLockTags.ToArray());
        }

        public InputLock LockInput(InputLockTag[] inputLockTags)
        {
            var inputLock = new InputLock(inputLockTags);

            foreach (var lockType in inputLock.InputLockTags)
            {
                if (_locks[lockType].Contains(inputLock.Guid))
                {
                    Debug.Log($"The lock {inputLock.Guid} is already locked");
                    return null;
                }

                _locks[lockType].Add(inputLock.Guid);
            }
            _idToInputLock.Add(inputLock.Guid,inputLock);
            UpdateLockables();

            return inputLock;
        }

        public void UnlockInput(InputLock inputLock)
        {
            foreach (var lockType in inputLock.InputLockTags)
            {
                if (!_locks[lockType].Contains(inputLock.Guid))
                {
                    Debug.Log($"You are trying to unlock {inputLock.ToString()} but no such inputlock exists");
                    return;
                }

                _locks[lockType].Remove(inputLock.Guid);
            }
            _idToInputLock.Remove(inputLock.Guid);

            UpdateLockables();
        }

        private void UpdateLockables()
        {
            foreach (var inputLockable in _inputLockSubscribers)
            {
                UpdateLockable(inputLockable);
            }
        }

        private void UpdateLockable(BaseInputLockable inputLockable)
        {
            var shouldLock = ShouldLockInputLockable(inputLockable);

            if (shouldLock)
            {
                inputLockable.Lock();
            }
            else
            {
                inputLockable.Unlock();
            }
        }

        private bool ShouldLockInputLockable(BaseInputLockable inputLockable)
        {
            var shouldLock = false;
            foreach (var tag in inputLockable.Tags)
            {
                var isLocked = _locks[tag].Count != 0;
                if (isLocked)
                {
                    shouldLock = true;
                    break;
                }
            }

            return shouldLock;
        }
    }

let's check that the code works with some unit tests:

    public class InputServiceTest
    {
        private InputLockService _inputLockService;

        [SetUp]
        public void Setup()
        {
            _inputLockService = new InputLockService();
        }

        [Test]
        public void LockInputTest()
        {
            var lockTags = new[] { InputLockTag.Cube };
            _inputLockService.LockInput(lockTags);


            Assert.True(_inputLockService.IsTagLocked(InputLockTag.Cube));
        }

        [Test]
        public void LockInputUnlockTest()
        {
            var lockTags = new[] { InputLockTag.Cube };
            var inputLock = _inputLockService.LockInput(lockTags);
            _inputLockService.UnlockInput(inputLock);

            Assert.False(_inputLockService.IsTagLocked(InputLockTag.Cube));
        }

        [Test]
        public void TwoLocksOneUnlockTest()
        {
            var lockTags1 = new[] {InputLockTag.Cube};
            var lockTags2 = new[] { InputLockTag.Cube };

            var inputLock1 = _inputLockService.LockInput(lockTags1);
            var inputLock2 = _inputLockService.LockInput(lockTags2);

            _inputLockService.UnlockInput(inputLock1);

            Assert.True(_inputLockService.IsTagLocked(InputLockTag.Cube));
        }

        [Test]
        public void LockAllInputs()
        {
            _inputLockService.LockAllInputs();
            var inputLockTags = Enum.GetValues(typeof(InputLockTag)).OfType<InputLockTag>().ToList();
            var areAllTagsLocked = true;
            foreach (var tag in inputLockTags)
            {
                areAllTagsLocked = _inputLockService.IsTagLocked(tag) && areAllTagsLocked;
            }
            Assert.True(areAllTagsLocked);
        }

        [Test]
        public void LockAllInputsExcept()
        {
            _inputLockService.LockAllExcept( new[] { InputLockTag.Cube });
            var inputLockTags = Enum.GetValues(typeof(InputLockTag)).OfType<InputLockTag>().ToList();
            inputLockTags.Remove(InputLockTag.Cube);
            var areAllTagsLocked = true;
            foreach (var tag in inputLockTags)
            {
                areAllTagsLocked = _inputLockService.IsTagLocked(tag) && areAllTagsLocked;
            }
            Assert.True(areAllTagsLocked && !_inputLockService.IsTagLocked(InputLockTag.Cube));
        }

    }

we can run the test runner now to see if the tests work.

Window → General → TestRunner

And now we can be confident that the code works :D.