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.
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
.
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.
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 scrollTimeInSeconds
sets 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).
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
.
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.