Tips for writing cleaner code that scales in Unity Part 6: Expanding interactions with locks, keys, and breakable doors

We’re extending our cleaner code series to show the payoff of the principles we explored in articles 1–5 in real, game-like scenarios. A well-designed foundation makes new features easier to add, and existing features safer to change and reusable – so let’s prove it.

In Article 6, we’ll layer door interactions onto the existing project; we’ll start with open/close action, then let the player destroy the door using the existing weapon, and finally add locking/unlocking with a key. The goal is to demonstrate how easily a well-designed foundation, using the principles from earlier articles, lets us extend the codebase with a new gameplay mechanic.


In Article 6 of our cleaner code series, we’ll layer door interactions onto the existing project.

To follow along make sure to visit our GitHub repository and download the project. Next open it in Unity 6.2 using the Add from disk button and open the scene via Scripts > Article 6 > Start > Article 6 Starter Project. You can find the finished version of the project in the Scripts > Article 6 > Result folder by opening the Article 6 Result Project scene.

Opening and closing doors interaction

In our project, we already implemented the IInteractable interface that allows us to implement new interactions without changing the existing classes.

public interface IInteractable
    {
        void Interact(GameObject interactor);
    }

If you are following along using the GitHub project, notice that we are using namespaces for article 6, so every script is inside the namespace Tips.Part_6_Start. Using namespaces is considered a good practice for separating concerns. You can find the end project for this article in the _Scripts > Article 6 > Result projects and the namespace will be namespace Tips.Part_6_End.

Namespaces

Namespaces group related classes by responsibility, not by convenience. They make architectural boundaries explicit, helping prevent unrelated systems from leaking into each other. By organizing code around what it does rather than where it lives, namespaces improve readability, reduce coupling, and make Unity projects easier to scale.

Read more about the namespaces in our C# style guide ebook.

In our scene, we have a Doors_Start object made out of a door model, doorframe, and part of the wall. We want the door to open or close based on its current state when the player interacts with it. To do this, we’ll create a DoorInteractable script:

public class DoorInteractable : MonoBehaviour, IInteractable
    {
        [SerializeField]
        private DoorController m_doorController;
        public void Interact(GameObject interactor)
        {
            if (m_DoorController.AnimationDone)
            {
                m_DoorController.Toggle();
            }
        }
    }

Here we implement our existing IInteractable interface to enable the interaction. Because we need to animate the door object this script focuses on triggering the interaction. The DoorController script will take care of the door specific logic.

public class DoorController : MonoBehaviour
    {
        [SerializeField]
        private Animator m_animator;

        public bool AnimationDone { get; private set; } = true;
        private bool m_doorClosed = true;
        
        public void Open(){
            …
        }

        public void Close() {
            …
        }

        ...

        public void Toggle()
        {
            if(m_doorClosed)
                Open();
            else
                Close();
        }
    }

This is another example of the single-responsibility principle in action (introduced in Article 1) . The DoorInteractable script is responsible for calling any logic that should run if the player interacts with our door object. How the Door object works is not a concern of this script.


The Door_Start object in the Hierarchy, expanded to show its child objects, including the Door child object

If you’d like to follow along and reproduce the steps, select the Door_Start object in the Hierarchy and expand it to find the Door child object. The Animator and DoorAnimation components are already added. We need to add our DoorInteractable script to this child object and assign the fields.


Diagram showing DoorInteractable implementing IInteractable interface and delegating door state and animation to DoorController

If we press Play and use arrow keys to walk to the door object, we can interact with it by left-clicking on the mouse, opening and closing the door based on its current state.

Unity_cleaner code that scales_Press Play and use arrow keys to walk to the door object and interact with it by left-clicking on the mouse.
Press Play and use arrow keys to walk to the door object and interact with it by left-clicking on the mouse.

Destructable doors

You previously added a weapon that the player can pick up and use and also created an IDamageable interface that the weapon object can interact with by calling the TakeDamage(..) method. Let’s allow the player to hit the door object to break it.
Yor already have a Health script to reuse for the Door so that it takes a specific number of hits to break this object:

public class Health : MonoBehaviour, IDamageable
    {
        [field: SerializeField]
        public int CurrentHealth { get; private set; }

        [SerializeField]s
        private int m_maxHealth = 2;
        [SerializeField]
        private bool m_isInvincible = false;

        public event Action OnHit;

                ...



        public void TakeDamage(DamageData damageData)
        {
            if (m_isInvincible)
                return;
            CurrentHealth -= damageData.DamageAmount;
            OnHit?.Invoke();
        }
    }

To destroy the door object, lets create a new script called DestroyObjectFeedback:

public class DestroyObjectFeedback : MonoBehaviour
    {
        [SerializeField]
        private Health m_health;
        [SerializeField]
        private GameObject m_objectToDestroy;
        private void Awake()
        {
            m_health.OnHit += PlayFeedback;
        }

        private void PlayFeedback()
        {
            if (m_health.CurrentHealth > 0)
                return;
            Destroy(m_objectToDestroy);
        }
    }

In this script, you’re relying on the OnHit event of the Health and the CurrentHealth parameters. You need to have a custom CurrentHealth check before triggering the feedback. The code can be improved but it’s good enough for now.


The Door object with the DoorInteractable, Health, and DestroyObjectFeedback scripts attached

In the project, select the Door_Start object and the Door child object. Add a Health component to it (Part_6_Start namespace) and set the Current Health and Max Health to the value of 2. This means it will take two hits to break the door.


Current design where DestroyObjectFeedback depends directly on Health and listens to the OnHit event, checking if CurrentHealth is less or equal to 0

Next, add the DestroyObjectFeedback script to the Door child object. It exposes m_health and m_objectToDestroy as serialized fields. You’ll assign the Door child object to both of those in the Inspector.

image28

Press Play and pick up the weapon from the box on the right. You can equip it by pressing the Z key. If you walk toward the floor object and left-click with the mouse, you should be able to hit the door object and destroy it.

In this scene, the door shakes and spawns particles when hit because you have added the same ShakeTranformEffect feedback script as in the tree object in the background to the door object.


The ShakeTransformEffect script attached to Door object, with parameters to set the intensity and duration of the shake effect

You should notice another child object called Door_Broken that is disabled. It’s a version of the door model broken into pieces so that we can add visual feedback to the door’s destruction.


The disabled Door_Broken child object

Let’s create a new script called ShowBrokenObjectFeedback, which should look like this:

public class ShowBrokenObjectFeedback : MonoBehaviour
    {
        [SerializeField]
        private Health m_health;
        [SerializeField]
        private GameObject m_objectToEnable;
        private void Awake()
        {
            m_health.OnHit += PlayFeedback;
        }

        private void PlayFeedback()
        {
            if (m_health.CurrentHealth > 0)
                return;
            m_objectToEnable.SetActive(true);
        }
    }

This logic should look familiar. It’s almost the same as in the previous script, including repeating the same CurrentHealth check.

Before we address the repeated code, add the ShowBrokenObjectFeedback script to the Door child object. Assign the Health reference to the Door object, and choose Door_Broken as the Object to Enable.


ShowBrokenObjectFeedback and DestroyObjectFeedback depend directly on Health and listens to the OnHit event. In both scripts, the CurrentHealth is used in the less or equal to 0 check.

Press Play and test. When you destroy the original door, Door_Broken becomes active. Because it’s built from separate pieces – each with a Rigidbody and Collider – the broken door immediately falls apart, providing visual feedback for the destruction.

Unity_Cleaner code that scales_In Play mode, when you destroy the original door, Door_Broken becomes active.
In Play mode, when you destroy the original door, Door_Broken becomes active.

Let’s look at ShowBrokenObjectFeedback, specifically the
if(CurrentHealth > 0) return; logic. We do the same check in DestroyObjectFeedback, and we’ll likely repeat it in any new behavior that depends on this state. That duplication is risky: if the condition at some point changes, we risk updating one script and forgetting the other. This is exactly what the DRY (Don’t Repeat Yourself) principle warns about: keep a single, authoritative place for a piece of logic instead of scattering copies across multiple files.

DRY: Don’t Repeat Yourself

The DRY principle is about eliminating duplicated knowledge, not typing less code. It states that every piece of knowledge must have a single, unambiguous, authoritative representation within a system. Otherwise you risk bugs and drift: one script forgets the check, another handles it differently, and future changes require updating several files.

Read more about the DRY principle on Wikipedia.

This example is missing an additional OnDie event that the Health script would emit when the Health value drops to or below zero. In this case you own the Health script and can modify it. But what if you’re working on a bigger project where the Health script is part of a codebase that you don’t own? If you make the changes while someone else is also working on this script, this can produce a conflict when you both submit the changes to your shared source control; it might even break the changes made by the other person.

Applying Adapter thinking to extend existing logic

The problem is that the Health class is missing an OnDie event and feedback classes like ShowBrokenObjectFeedback end up repeating the same if check to detect when health reaches zero. This example assumes the Health class does not belong to your codebase, and so avoid modifying it directly.

Whenever you need to adjust your code architecture, look at design patterns as general solutions to common problems. Let’s take a closer look at the Adapter pattern.

Adapter Design Pattern

The Adapter pattern is a structural design pattern that makes otherwise incompatible classes work together. It wraps an existing class and exposes a different interface that clients expect, allowing behavior to be reused without modifying the original implementation.

At its core, the Adapter pattern promotes extension without modification, which is especially valuable when working with third-party, legacy, or shared codebases.

Learn more about design patterns in the e-book, Level up your code with design patterns and SOLID ebook.

The broader idea behind the Adapter pattern is that you want to extend or reinterpret existing behavior without changing the original class. In our case, the problem is not an incompatible interface, but an incomplete semantic one.

The Health class exposes low-level information about health value changes, but it does not communicate a high-level concept, such as death, which our feedback classes require.

Rather than modify the Health class directly, introduce a separate component that observes it and derives meaning from its events. This component translates a low-level signal into a high-level domain event that other systems can safely depend on.

Let’s create a new script called HealthDeathObserver:

public class HealthDeathObserver : MonoBehaviour
{
    [SerializeField] private Health m_health;

    public event Action OnDied;        
    void OnEnable()  { m_health.OnHit += HandleHit; }   
    void OnDisable() { m_health.OnHit -= HandleHit; }

    void HandleHit()
    {
        if (m_health.CurrentHealth <= 0) 
            OnDied?.Invoke();
    }

}

The HealthDeathObserver is a MonoBehaviour class with a reference to the Health object. You move the repeated code from the earlier classes into this component and expose the OnHit event that our feedback classes can use instead.

The update feedback scripts would look like this:

public class DestroyObjectFeedback : MonoBehaviour
{
    [SerializeField]
    private HealthDeathObserver m_healthDeathObserver;
    [SerializeField]
    private GameObject m_objectToDestroy;

    private void OnEnable()
    {
        m_healthDeathObserver.OnDied += PlayFeedback;
    }

    private void OnDisable()
    {
        m_healthDeathObserver.OnDied -= PlayFeedback;
    }

    private void PlayFeedback()
    {
        Destroy(m_objectToDestroy);
    }
}

And like this one:

public class ShowBrokenObjectFeedback : MonoBehaviour
{
    [SerializeField]
    private HealthDeathObserver m_healthDeathObserver;
    [SerializeField]
    private GameObject m_objectToEnable;
    private void OnEnable()
    {
        m_healthDeathObserver.OnDied += PlayFeedback;
    }

    private void OnDisable()
    {
        m_healthDeathObserver.OnDied -= PlayFeedback;
    }

    private void PlayFeedback()
    {
        m_objectToEnable.SetActive(true);
    }
}

Add the HealthDeathObserver component onto the Door child object and assign it as a field to both of the feedback classes. The project should work the same as previously. The difference is that you’ve eliminated the duplicated code and created a way to add more functionality to the Health class without modifying the class itself.


DestroyObjectFeedback and ShowBrokenObjectFeedback now depend on HealthDeathObserver and listen to its OnDied event.

The scene also contains a DestructibleBox object, which in turn has a “Broken” version of itself as a child object. Let’s apply the same script to it to allow the player to destroy this object.


The DestructibleBox object has a “Broken” version of itself as a child object.

Now you can also destroy the box:

Unity_Cleaner code that scales_The DestructibleBox object and its child object Broken in action in the sceneains a DestructibleBox object, which in turn has a “Broken” version of itself as a child object. Let’s apply the same script to it to allow the player to destroy this object.
The DestructibleBox object and its child object called Broken in action in the scene

In the next section, we’ll show the steps for adding some loot that is spawned when the box is destroyed, specifically, a key that will open locked doors.

The locked door: Extending interactions with composition

So far, the door is always unlocked. When the player interacts and the animation is done, DoorController toggles between open and closed. Most games quickly need more sophisticated interactions, like locked doors, keys hidden in chests, maybe lockpicks or codes.

You could cram this logic into DoorInteractable, but that class would grow with every feature and risk breaking what already works. Instead, use composition by introducing a small IAccessRule policy that describes when the door can be used. DoorInteractable will consult this rule before asking DoorController to perform the action. The simplest rule for this use case is UnlockedRule (nothing required), and additional rules (like a “key required” rule) can be added without changing the door code.

Start by spawning the Key_Start prefab in the scene when you destroy the Box object. You can find this prefab in the Project tab inside the Article 6 > Start folder:


Spawn the Key_Start prefab in the scene when you destroy the Box object.

To instantiate it on destruction, create a new script called ShowLootFeedback and add it to the box. This component listens to the HealthDeathObserver and, when its OnDied event fires, spawns all prefabs assigned to m_itemsToSpawn at the Box’s position:

public class SpawnLootFeedback : MonoBehaviour
{
    [SerializeField]
    private HealthDeathObserver m_healthDeathObserver;

    [SerializeField]
    private List<GameObject> m_itemsToSpawn;

    private void OnEnable()
    {
        m_healthDeathObserver.OnDied += ShowLoot;
    }

    private void OnDisable()
    {
        m_healthDeathObserver.OnDied -= ShowLoot;
    }
    private void ShowLoot()
    {
        foreach (var item in m_itemsToSpawn)
        {
            if (item != null)
            {
                Instantiate(item, transform.position, Quaternion.identity);
            }
        }
    }
}

It subscribes to HealthDeathObserver.OnDied in Awake(). When the box “dies” ShowLoot() runs and instantiates each non-null prefab from m_itemsToSpawn at transform.position with Quaternion.identity. This lets you drop one or more loot items (e.g., Key_Start) without touching the damage/health code.


SpawnLootFeedback makes use of the HealthDeathObserver class and its OnDied event.

Add the ShowLootFeedback script to the Box object in your scene. Assign the HealthDeathObserver reference by dragging the Box object. Add the Key_Start prefab to the list of items to spawn.


The ShowLootFeedback script added to the Box object in the scene

Before testing the key spawn, let’s create a very basic Inventory class:

public class Inventory : MonoBehaviour
{
    [SerializeField]
    private bool m_hasKey = false;
    public bool HasKey => m_hasKey;

    public void PickUpKey()
    {
        m_hasKey = true;
    }

    public void RemoveKey()
    {
        m_hasKey = false;
    }
}

This is enough for the locked-door test: the key will flip HasKey to true. Add Inventory to the Ellen_6_Start (player) object so you can access it from interactions that receive the player GameObject.


Add the Inventory script to the Ellen_6_End player object

Next let’s create a new script called KeyPickUpInteractable that will implement the existing IInteractable component:

public class KeyPickUpInteractable : MonoBehaviour, IInteractable
{
    public void Interact(GameObject interactor)
    {
        Inventory inventory = interactor.GetComponent<Inventory>();
        if (inventory != null)
        {
            inventory.PickUpKey();
            Destroy(gameObject);
            Debug.Log("Key picked up");
        }
    }
}

Since the Interact method from the IInteractable interface expects a reference to the interactor object, you can get access to the Inventory component placed on the player object by adding the key to it.

Add the KeyPickupInteractable script to the Key_Start prefab. It should already have a Box Collider (set to Is Trigger), the Highlight script from the earlier article, and its Layer set to Interactive.


Add the KeyPickupInteractable script to the Key_Start prefab

Destroy the box, wait for the debris to clear, press Z to hide the weapon, and left-click to pick up the key:
Unity_cleaner code that scales_Ellen player picking up the key
The Ellen player picking up the key

After picking it up, check the Inventory script component on Ellen_6_Start. The HasKey flag should now be true (you can also toggle it manually during the next test when you verify that the door opens with the key):

The next step is to introduce locked doors without breaking DoorInteractable, which theoretically could already be used elsewhere. This is a common scenario: requirements change, but you don’t want to risk regressions in stable code.

Instead of hard-coding locked/unlocked logic inside the door, use composition to move that logic into a separate, swappable rule object that the door consults before opening. Refactor to a small interface called IAccessRule. It will describe the conditions required to use (open) the door. DoorInteractable will consult this rule before asking DoorController to animate the door. You’ll start with an UnlockedRule that preserves the current behavior, so nothing breaks, while giving you a clean way to add new rules later without changing door code.

Create a new folder called AccessRules and a IAccessRule interface:

public interface IAccessRule
{
    bool TryAuthorize(GameObject interactor);
}

To refactor DoorInteractable to use IAccessRule, first define UnlockedRule. It preserves the current “always usable” behavior, so existing door prefabs keep working.

public class UnlockedRule : MonoBehaviour, IAccessRule
{
    public bool TryAuthorize(GameObject interactor)
    {
        return true;
    }
}

To finish the refactoring, modify the DoorInteractable script.

public class DoorInteractable : MonoBehaviour, IInteractable
{
    [SerializeField]
    private DoorController m_doorController;
    private IAccessRule m_accessRule;
    private void Awake()
    {
        m_AccessRule = GetComponent<IAccessRule>();
        if (m_AccessRule == null)
            m_AccessRule = gameObject.AddComponent<UnlockedRule>();
    }
    public void Interact(GameObject interactor)
    {
        if (m_AccessRule.TryAuthorize(interactor) == false)
            return;
        if (m_DoorController.AnimationDone)
        {
            m_DoorController.Toggle();
        }
    }
}

This refactor preserves all existing behavior while making the door extensible. At Awake(), DoorInteractable looks for any component on the same GameObject that implements IAccessRule:

  • If one is found (e.g., a future LockedRule), it will be used to authorize interaction.
  • If none is found, add UnlockedRule at runtime, which authorizes access.

As a result, current prefabs need no changes; they continue to open/close exactly as before, while you can create new door variants by attaching a different rule component. This uses composition instead of growing DoorInteractable, keeps responsibilities small, and avoids breaking working code.

Since interfaces can’t be serialized in the current Unity version, you resolve the rule at runtime with GetComponent<IAccessRule>(). That means you should document how designers apply rules to doors. What’s obvious to the author of the code isn’t always obvious to the rest of the team.


DoorInteractable depends on an IAccessRule to authorize interaction. With UnlockedRule (an IAccessRule implementation), behavior stays the same as before, while making it easy to add new access rules later without modifying DoorInteractable

Now the doors behave exactly as before. Feel free to test the project before continuing.
With IAccessRule in place, let’s add a new rule:

public class LockedRule : MonoBehaviour, IAccessRule
{
    [SerializeField]
    private bool m_consumeKey = false;
    [SerializeField]
    private bool m_isLocked = true;

    public bool TryAuthorize(GameObject interactor)
    {
        if (m_isLocked == false)
            return true;
        Inventory inventory = interactor.GetComponent<Inventory>();
        if (inventory == null)
            return false;
        if (inventory.HasKey == false)
            return false;
        if (m_consumeKey)
            inventory.RemoveKey();
            m_isLocked = false;
        return true;
    }
}

This rule encapsulates the key-required logic for a locked door:

  • The door starts locked (m_isLocked = true;).
  • Access is denied unless the interactor has an Inventory with HasKey == true;.
  • If m_consumeKey is enabled, the key is removed on first use.
  • Once authorized, the rule flips to unlocked (m_isLocked = false;), allowing subsequent interactions.

You can extend this approach in several ways:

  • Add item IDs to Inventory and check for a specific key type.
  • Create additional rules (e.g., LockpickRule, CodePanelRule, QuestFlagRule) without changing door code.
  • Reuse the same rules on chests, windows, consoles – anything that should gate interaction.


Thanks to IAccessRule, you can define new rules (like LockedRule) without editing DoorInteractable. This preserves stability and exemplifies the Open Closed Principle (OCP).

To use this rule, add LockedRule to the same GameObject as DoorInteractable. Since DoorInteractable resolves its rule via GetComponent<IAccessRule>(), attaching LockedRule overrides the default UnlockedRule behavior.

Now, the door should remain locked if you try to open it. To proceed, either tick HasKey on the player’s Inventory component or destroy the box, pick up the key, and interact with the door again:
image2

In this section we applied composition and the ope-closed principle (OCP). Instead of bolting new logic onto DoorInteractable, we introduced the IAccessRule interface. To be able to use it while preserving the current behavior we have defined the UnlockedRule. With that seam in place, new functionality is added (e.g., LockedRule) without modifying the door code.

Conclusion

In this article, you learned how a well-designed codebase makes new features straightforward to add and reinforces the single-responsibility principle by keeping responsibilities small and separable (interaction vs. door behavior vs. feedback).

The DRY principle is applied by centralizing shared logic and, when direct edits weren’t desirable, the Adapter pattern is used to extend behavior without modifying the original class.

Finally, you practiced composition and the open-closed principle by introducing the IAccessRule and having DoorInteractable depend on that interface. This preserved existing behavior (via UnlockedRule) while enabling new variants (like LockedRule). We modified DoorInteractable, but instead of hard-coding “locked door” logic, it was refactored to depend on the interface first. This ensured that the existing doors kept working. Next, LockedRule was added as a composable policy. The result is a fully functional, yet simple, door system that you can extend in the future by adding rules instead of rewriting code.

2 Likes