How to Make a Cylindrical Combination Lock In Unity, Part I

5th January 2025 • 16 min read

In this new series of Unity tutorials, we'll build a cylindrical combination lock, commonly found on suitcases and often featured in game puzzles. We'll be able to set initial and target combinations and register callbacks for OnTargetValueEnter and OnTargetValueExit to integrate the lock into any game logic.

In the first part, we'll create a foundation and manually assemble a prefab with four digits as an example. In the second part, however, we'll build an editor tool to generate the lock procedurally by simply specifying the number of digits, initial and target values, and pressing the Create Lock button.

If you'd like to follow along, and as always, I encourage you to do so, I recommend using Unity 6000.0.32. Although, the implementation relies only on the basic Unity Engine API, so the chances of it working in other versions without issues are quite high.

You'll also need some prototype meshes and materials that I created in Blender and imported into the example project on GitHub. You can find them in the Assets/Cylinder/Models directory.

Assets Preparation I

Let's start with an empty scene, where you will create an empty game object and name it Lock. Then, create a child object of Lock named Cylinder, and under Cylinder, create two additional child objects: ColliderUp and ColliderDown.

Now, add a Cylinder mesh from the Models directory mentioned earlier, move it in the hierarchy under the Cylinder game object, rename it to CylinderMesh, and zero the Transform positions of all these objects.

The hierarchy of your scene should look like this.

Next, add a Sphere Collider component to the ColliderUp object, set the position of Transform component to { X: 0.75, Y: 0.4, Z: 0 }, and the Radius of the collider component to 0.33. Repeat the same steps for ColliderDown but set the Y component of the position to -0.3.

Sphere Collider components of ColliderUp and ColliderDown game objects.

Later, we will use these colliders to interact with the cylinder using ray casting.

On Hit Handler Script

Now, let's add a simple script to both ColliderUp and ColliderDown. We'll call it OnHitHandler and give it the following content.

using System;
using UnityEngine;

public class OnHitHandler : MonoBehaviour
{
    private Action _callback;

    public void SetCallback(Action callback)
    {
        _callback = callback;
    }

    public void Invoke()
    {
        _callback.Invoke();
    }
}

This will allow each collider to hold an Action. In a moment, we will register a callback to move the cylinder mesh up or down and update the underlying value via the SetCallback function and this logic will be executed through the Invoke function upon interaction.

The OnHitHandler is added as a custom component to both the ColliderUp and ColliderDown objects.

Cylinder and Lock Scripts

Now, add two new scripts. One that will be attached to the Lock and another to the Cylinder game objects. The Lock will be responsible for keeping the overall value set together by the individual cylinders and for broadcasting events to listeners of the onTriggerValueEnter and onTriggerValueExit events.

The Cylinder script will handle the scroll animation, keep its own single digit value, and report changes of this value to the Lock. Let's start by implementing the Cylinder script. First, we need a couple of members.

using System.Collections;
using UnityEngine;

public class Cylinder : MonoBehaviour
{
    public GameObject cylinder;
    public OnHitHandler handlerUp;
    public OnHitHandler handlerDown;
    public float scrollTimeInSeconds = 0.33f;

    private int _value;
    private int _order;
    private Lock _parentLock;
    private bool _isRotating;

The cylinder will reference the CylinderMesh game object, which currently exists under Cylinder in the hierarchy. We'll set this reference via the Inspector, just as we do for the handlerUp and handlerDown components of ColliderUp and ColliderDown, respectively.

The scrollTimeInSecondssets how fast the cylinder rotates from one digit to another, in other words, by 36 degrees from one face to another. This is because our cylinder is a regular decagon with 10 digits, from 0 to 9, on each side. 1/3 of a second feels like an optimal duration.

The _value will hold the currently selected value, matching the side pointing outward and perpendicular to the lock case, which we’ll assemble later, while the _order will be assigned by the parent lock based on the cylinder's position. The most-right cylinder will have an order of 0, the next one (for tens) will be 1, the next (for hundreds) will be 2, and so on.

The _parentLock is a reference to an instance of the Lock class that we yet need to implement, and _isRotating will be used as a state flag to prevent the user from interacting with the cylinder before it finishes the rotation animation from a previous interaction.

Let's now continue by implementing SetParentLock, a public method that will later be called from Lock class.

public void SetParentLock(Lock parentLock, int order, int value)
{
    _parentLock = parentLock;
    _order = order;
    _value = value;

    cylinder.transform.rotation *= Quaternion.Euler(0, -value * 36, 0);
}

Here, a parent lock reference is set for the cylinder, tells the cylinder its order, and also sets the initial value and rotating the cylinder mesh so that the face with the matching value is properly oriented. We can think of SetParentLock as the initialization function.

Next, we need to implement the RotateCylinder method, which will be called as a Coroutine to gradually rotate the cylinder toward the next or previous value during interactions.

private IEnumerator RotateCylinder(float angle)
{
    _isRotating = true;
    float elapsed = 0f;
    Quaternion initialRotation = cylinder.transform.rotation;
    Quaternion targetRotation = initialRotation * Quaternion.Euler(0, angle, 0);

    while (elapsed < scrollTimeInSeconds)
    {
        elapsed += Time.deltaTime;
        float t = elapsed / scrollTimeInSeconds;
        cylinder.transform.rotation = Quaternion.Lerp(initialRotation, targetRotation, t);
        yield return null;
    }

    // Ensure final position
    cylinder.transform.rotation = targetRotation; 

    _value -= (int)(angle / 36);
    if (_value > 9) _value = 0;
    if (_value < 0) _value = 9;

    _parentLock.SetDigit(_value, _order);

    _isRotating = false;
}

Notice that we also update the value by decrementing the angle divided by 36 (1/10 of a full circle in degrees) and report this new value, along with the cylinder's order, to the parent lock via the SetDigit method, which we still need to implement.

But before that, let's finish the Cylinder class by adding the ScrollUp and ScrollDown methods, which will invoke RotateCylinder as coroutine, and register these methods as callbacks to the handlerUp and handlerDown instances of our OnHitHandler in the Awake method.

public void Awake()
{
    handlerUp.SetCallback(ScrollUp);
    handlerDown.SetCallback(ScrollDown);
}

private void ScrollDown()
{
    if (_isRotating)
        return;

    StartCoroutine(RotateCylinder(36));
}

private void ScrollUp()
{
    if (_isRotating)
        return;

    StartCoroutine(RotateCylinder(-36));
}

Let's proceed by implementing the Lock class, which is fairly simple, starting with defining its member variables.

using UnityEngine;
using UnityEngine.Events;

public class Lock : MonoBehaviour
{
    public int triggerValue = 4096;
    public int value = 256;

    public UnityEvent onTriggerValueEnter;
    public UnityEvent onTriggerValueExit;
    private bool _wasTriggerValueEntered;

The value and triggerValue are arbitrary numbers, you can set them to any positive numbers as long as they don't exceed 9999. This limit is due to the fixed number of four cylinders in our current setup. In the next part of this tutorial, we will implement a tool to generate the lock procedurally with any number of cylinders.

The value will be initially displayed on the lock, while the OnTriggerValueEnter event will be triggered when the player rotates the cylinders to match the triggerValue. At that point, the _wasTriggerValueEntered flag will be set and when the player changes the value again by rotating any of the cylinders, the OnTriggerValueExit event will be invoked, and _wasTriggerValueEntered will be set back to false.

This will be handled by the SetDigit method, which we call from the Cylinder.RotateCylinder method that we finished just a while ago.

public void SetDigit(int digit, int order)
{
    int divisor = (int)Mathf.Pow(10, order);
    int rightPart = value % divisor;
    int leftPart = value / (divisor * 10);

    value = leftPart * (divisor * 10) + digit * divisor + rightPart;

    if (value == triggerValue)
    {
        _wasTriggerValueEntered = true;
        onTriggerValueEnter?.Invoke();
    }
    else if (_wasTriggerValueEntered && value != triggerValue)
    {
        _wasTriggerValueEntered = false;
        onTriggerValueExit?.Invoke();
    }
}

Apart from broadcasting events when the trigger value is set or eventually unset, the SetDigit method updates the overall lock value using a formula that replaces the digit at the specified order while preserving the left and right parts of the number.

In the Awake function, we now just need to iterate over all Cylinder components on cylinders under the Lock game object using GetComponentsInChildren and for each cylinder register this lock as its parent lock so they can report their value changes via the SetDigit method, and set their orders and initial values based on the initial value of this lock.

public void Awake()
{
    Cylinder[] cylinders = GetComponentsInChildren<Cylinder>();

    for (int i = 0; i < cylinders.Length; i++)
    {
        int digit = (value / (int)Mathf.Pow(10, i)) % 10;
        cylinders[i].SetParentLock(this, i, digit);
    }
}

Now, go back to the Unity Editor, select the Cylinder game object under the Lock in scene hierarchy, and assign its child objects CylinderMesh, ColliderUp, and ColliderDown in the Inspector to the Cylinder, HandlerUp, and HandlerDown fields, respectively.

Materials Switcher Script

This script will handle switching the cylinder materials when a trigger value is set and unset. In the Models directory, you’ll find Cylinder_Red.mat alongside Cylinder_Green.mat, which we’ll need shortly. But first, create a new C# script, name it MaterialsSwitcher, and attach it to the Lock game object in the scene hierarchy.

The MaterialsSwitcher class needs a two-dimensional, serializable list of materials. In this particular example, we'll be switching only sets of materials with one material, but models typically have more than one material.

Having a serializable 2D list will allow us to assign these material sets conveniently in the Unity Editor via the Inspector. Let's define this structure with Serializable attribute at the top of the MaterialsSwitcher.cs file.

using System;
using System.Collections.Generic;
using UnityEngine;

[Serializable]
public class SerializableList<T>
{
    public List<T> list;
}

[Serializable]
public class SerializableList2D<T>
{
    public List<SerializableList<T>> lists;

    public List<T> this[int row]
    {
        get => lists[row].list;
        set => lists[row].list = new List<T>(value);
    }
}

Overloading the [] operator will simplify accessing elements of the SerializableList2D, making it more intuitive to work with the 2D list structure as if it were a standard array. The implementation of the MaterialsSwitcher is now simple. We just need our list of material sets, a list of renderers, and two public methods: AddRenderer, for adding renderers from mesh instances we wish to switch materials on, and SetMaterials, to iterate over these renderers and set materials from the materials list by the given index.

public class MaterialsSwitcher : MonoBehaviour
{
    public SerializableList2D<Material> materials;
    private readonly List<Renderer> _renderers = new();

    public void SetMaterials(int setIdx)
    {
        foreach (var r in _renderers)
        {
            r.SetMaterials(materials[setIdx]);
        }
    }

    public void AddRenderer(Renderer r)
    {
        _renderers.Add(r);
    }
}

To finish this, we need to revisit the Cylinder and Lock classes. In the Cylinder class, add a method that gets the renderer from cylinder and stores a reference in the _renderers list of the MaterialsSwitcher using the AddRenderer method we just implemented.

public void AddRendererTo(MaterialsSwitcher materialsSwitcher)
{
    var r = cylinder.GetComponent<MeshRenderer>();
    materialsSwitcher.AddRenderer(r);
}

In the Lock class, go to the Awake method and first get the MaterialsSwitcher component and then call AddRendererTo passing this reference for each cylinder in the for loop.

public void Awake()
{
+   var materialsSwitcher = GetComponent<MaterialsSwitcher>();

    Cylinder[] cylinders = GetComponentsInChildren<Cylinder>();

    for (int i = 0; i < cylinders.Length; i++)
    {
        int digit = (value / (int)Mathf.Pow(10, i)) % 10;
        cylinders[i].SetParentLock(this, i, digit);
+       cylinders[i].AddRendererTo(materialsSwitcher);
    }
}

Everything is now ready for you to go back to the Unity Editor, select the Lock, and via the Inspector add two lists to the Materials list with one list of materials for each, to which you assign Cylinder_Green.mat and Cylinder_Red.mat to elements 0 and 1, respectively.

Then add two events, one for OnTriggerValueEnter and one for OnTriggerValueExit, assign the Lock object itself, and select SetMaterials to be called for both of them. For OnTriggerValueEnter, set the argument to 0 (green material), and for OnTriggerValueExit, set the argument to 1 (red material).

This is how your Lock object should look in the Inspector now.

Later, you can add other events here. For example, you can add lock and unlock sounds, the opening and closing of doors, or anything you want. You can also change the initial and trigger values if you wish.

Assets Preparation II

We're almost done. Create three copies of the Cylinder object under Lock and arrange them next to one another along the Z-axis, spaced by their width, which is half a unit. The positions of the new cylinders should be:

{ X: 0, Y: 0, Z: 0.5 }
{ X: 0, Y: 0, Z: 1 }
{ X: 0, Y: 0, Z: 1.5 }

Be careful about their order in the hierarchy, they need to go from top to bottom as they go from right to left in the scene. Now, add one Case_Middle mesh and twoCase_Side meshes from the Models directory to your scene. Rename the sides to Case_Side_L and Case_Side_R, set the positions of all three new objects to zero, and make them child objects of the Lock.

At this point, the hierarchy of your scene should look like this.

Finally, set the Y value of the Scale property of the Case_Middle Transform component to 400, as there are four cylinders. Move it to the center to cover the top, bottom, and rear sides of the cylinders and position Case_Side_L and Case_Side_R to cover the left and right sides of the cylinder block, as shown in the following image. The width of the side blocks is the same as the cylinders, which is 0.5 units.

The Case_Middle, Case_Side_L, and Case_Side_R objects are purely for aesthetic purposes, they do not affect functionality in any way.

Raycaster Script for Interaction

To interact with the cylinders via ColliderDown and ColliderUp, you need a script that casts a ray from the viewport on mouse clicks into the scene and invokes callbacks registered to the OnHitHandlers on the game objects the colliders are attached to. We registered these callbacks in the Cylinder.Awake method.

In the actual game, this would likely be handled by some player controller, so consider this more of a testing script. You can read more about Physics.Raycast and Camera.ScreenPointToRay in the Unity documentation.

using UnityEngine;

public class Raycaster : MonoBehaviour
{
    Camera _mainCamera;

    private void Awake()
    {
        _mainCamera = Camera.main;
    }

    private void Update()
    {
        if (!Input.GetMouseButtonDown(0)) 
            return;

        Ray ray = _mainCamera.ScreenPointToRay(Input.mousePosition);
        if (!Physics.Raycast(ray, out RaycastHit hit)) 
            return;

        var onHitHandler = hit.collider.gameObject.GetComponent<OnHitHandler>();
        if (!onHitHandler)
            return;

        onHitHandler.Invoke();
    }
}

If you now create a new game object in the scene and attach this script to it as a component, you can hit play and test whether your lock is working properly. If it isn't, you can refer to the example project on GitHub.

That's it for today. Next time, we're going to implement an editor tool for generating these locks procedurally from sets of prefabs, turning the result of today's work into a conveniently reusable solution.

The Lock Factory will be implemented in Part II.