4.6 Game: Stairway to Heaven

Now lets use what you’ve learned to create a spiral stair challenge with steps that disappear at timed intervals. We’ll use CFrames to build the spiral stairs. Then use a loop to hide the steps at some interval.


We can break the the steps down to series of blocks arranged in a circle and rotated based on their angle in the circle.

Then each subsequent step is raise by a set amount and the depth the each step should be deep enough so that there is not a gap at the outer edges.

Next, we need a way to hide the steps and then unhide them. Or more accurately, hide them for some amount of time and then unhide them for some other amount.

We won’t hide them randomly, however. You might set that as a challenge once you’ve built it. Instead, we will create a cascade of hidden stairs that travel up the stairs to the top.

So this means we’ll use an array to iterate through a list of parts and hide/unhide them at the correct time.


Let’s start with the circle. We learned earlier that we could create a rotated CFrame and then multiply that with another CFrame to arrive at a final CFrame with an offset and orientation.

So if we create a rotated CFrame and multiply it with a CFrame that is shifted n studs along the X axis, that will give us a CFrame oriented in the correct direction at radius n.

Lua
local part = Instance.new("Part")
part.CFrame = CFrame.Angles(0, math.rad(30), 0) * CFrame.new(10, 0, 0)

part.Parent = game.Workspace

But that’s just for a single part and it would be tedious if we had to manually script each part and input each angle. So let’s use a loop to do it for us.

To create m number of blocks per a circle, the circle needs to be divided into m sections. More accurately, 360 degrees divided by m sections.

Lua
local folder = Instance.new("Folder")
folder.Parent = game.Workspace

local numberOfSteps = 36
local degreeIncrement = 360 / numberOfSteps

for i = 1, numberOfSteps do
	local part = Instance.new("Part")
	part.CFrame = CFrame.Angles(0, math.rad(i * degreeIncrement), 0) * CFrame.new(10, 0, 0)
	part.Size = Vector3.new(4, 1, 2)

	part.Parent = folder
end

From numberOfSteps we can calculate the angle between each step. Then multiplying that by the iterator get us the correct angle for each CFrame.


Now we need to figure out the dimensions for each step.

In the example, dimensions are “hardcoded.” This means that if we wanted to change the dimensions of the spiral or the steps in the future, we would have to go in and figure out the dimensions again.

What we need is a way to automatically calculate those dimensions.

We could set the dimensions based on the radius of the spiral. But that is also hardcoded in. And because the radius is measured to the center of the step, this will leave a gap at the outer edge between steps. For a wider spiral, that gap gets exacerbated.

Let’s fix this.

Instead of directly setting radius, we’ll set a target minor diameter. By dividing the minor diameter by some target step depth, it gives us the required number of steps per rotation. And then dividing that by the major circumference gives the required full depth of each step.

Lua
local folder = Instance.new("Folder")
folder.Parent = game.Workspace

local stepDepthTarget = 2
local stepWidth = 10

local minorDiameter = 45
local radius = minorDiameter / 2-- + stepWidth / 2

local minorCircumference = math.pi * minorDiameter --circumference=2*pi*r
local majorCircumference = math.pi * (minorDiameter + stepWidth)

local stepsPerCircle = minorCircumference / stepDepthTarget
local angleIncrement = 360 / stepsPerCircle

local stepDepth = majorCircumference / stepsPerCircle
local stepSize = Vector3.new(stepWidth, 1, stepDepth)

for i = 1, stepsPerCircle do
	local part = Instance.new("Part")
	part.CFrame = CFrame.Angles(0, math.rad(i * angleIncrement), 0) * CFrame.new(radius, 0, 0)
	part.Size = stepSize

	part.Parent = folder
end

The circle is missing the “0th” step but that’s okay. Subsequent rotations properly place it.

Height of each step is constant and rise per step is constant. So a step’s Y position is just it’s iterator times the step height.

Lua
...
local stepHeight = 1
...
...
...
local stepSize = Vector3.new(stepWidth, stepHeight, stepDepth)

for i = 1, stepsPerCircle do
...
	part.CFrame = CFrame.Angles(0, math.rad(i * angleIncrement), 0) * CFrame.new(radius, stepHeight * i, 0)
...
...
end

This is usable but there are still some limitations.

It’s limited to one full rotation. Sure we can add more rotations but to get a desired height, we would need to know the number of rotations or partial rotations, which itself already depends on other dimensions.

Let’s modify it so that that number of steps is automatically calculated based on a target height.

If you have a target height and know the step height, then the number of steps to reach that height is given by (target height)/(step height). And that number can be used for the iterator max.

Lua
local folder = Instance.new("Folder")
folder.Parent = game.Workspace

local stepDepthTarget = 2
local stepWidth = 10
local stepHeight = 1
local targetHeight = 1000

local minorDiameter = 45
local radius = minorDiameter / 2-- + stepWidth / 2

local minorCircumference = math.pi * minorDiameter --circumference=2*pi*r
local majorCircumference = math.pi * (minorDiameter + stepWidth)

local stepsPerCircle = minorCircumference / stepDepthTarget
local angleIncrement = 360 / stepsPerCircle

local stepDepth = majorCircumference / stepsPerCircle
local stepSize = Vector3.new(stepWidth, stepHeight, stepDepth)

local numberOfSteps = targetHeight / stepHeight

for i = 1, numberOfSteps do
	local part = Instance.new("Part")
	part.CFrame = CFrame.Angles(0, math.rad(i * angleIncrement), 0) * CFrame.new(radius, stepHeight * i, 0)
	part.Size = stepSize

	part.Parent = folder
end

And if you did all of that correctly, you should now have the set of spiral stairs.


Now let’s work on the loop that will hide/unhide the stair steps.

The most barebones to do this is to simply create two variables that countdown how long a part has been hidden/shown and then toggle it when the time runs out.

Lua
local part = Instance.new("Part")
part.Anchored = true
part.Parent = game.Workspace 

local hideTime = 1
local showTime = 2

local countdown = showTime

local lastTick = tick()

while true do
	local currentTick = tick() 
	local et = currentTick - lastTick
	countdown = countdown - et
	
	if countdown <= 0 then
		if part.CanCollide then
			part.CanCollide = false
			part.Transparency = 1.0
			countdown = hideTime
		else
			part.CanCollide = true
			part.Transparency = 0.0
			countdown = showTime
		end
	end
	
	lastTick = currentTick
	
	task.wait()
end

tick‘ is a Roblox global function (just like print) that returns the amount of time elapsed since the “UNIX epoch time” of 00:00:00 on January 1st, 1970.

Don’t worry what that means, all that matters is that it gives us an accurate way to measure time. If you record one tick and then record another one second later, the time difference will be very close to 1 second.

In the example, tick one time around the loop minus the previous tick yields the elapsed time. This is then subtracted from the countdown timer. Once the timers reach zero, reset the time and toggle collision/visibility of the part.


The example works for a single part but obviously entirely inadequate for potentially hundreds or more of parts.

Variables are only suitable for addressing single objects so a table will likely be needed. Plus a for loop for iterating through an array of elements.

And because we need to track a large number of part and also timers for those parts, we’ll need two arrays.

One way you certainly could do this is to create two separate arrays, one for the parts and another for timers. Then just be sure the index of each part in the parts list corresponds with the same timer in the timer array.

This results in two tables we need to keep track of though. It’s trivial at the moment, but as a project gets bigger, this can get disorganized quickly. Ideally, we should keep this related data together.

Fortunately, we can implement that using a table.

Recall that an array can store nested arrays. If we create a two-element array that couples the part with its timer, we’ll only need to track one array and use the loop to access the elements of the other.

Lua
local part1 = Instance.new("Part")
local part2 = Instance.new("Part")
local part3 = Instance.new("Part")

part1.Anchored = true
part2.Anchored = true
part3.Anchored = true

part1.Position = Vector3.new(0, 1, 0)
part2.Position = Vector3.new(5, 1, 0)
part3.Position = Vector3.new(10, 1, 0)

part1.Parent = game.Workspace
part2.Parent = game.Workspace
part3.Parent = game.Workspace

local PART_INDEX = 1
local TIMER_INDEX = 2

local hideTime = 1
local showTime = 2

local partsList = {{part1, showTime}, {part2, showTime}, {part3, showTime}}

local lastTick = tick()

while true do
	local currentTick = tick() 
	local et = currentTick - lastTick

	for _, partAndTimer in ipairs(partsList) do
		partAndTimer[TIMER_INDEX] = partAndTimer[TIMER_INDEX] - et
		local timeLeft = partAndTimer[TIMER_INDEX]
		local part = partAndTimer[PART_INDEX]

		if timeLeft <= 0 then
			if part.CanCollide then
				part.CanCollide = false
				part.Transparency = 1.0
				partAndTimer[2] = hideTime

			else 
				part.CanCollide = true
				part.Transparency = 0.0
				partAndTimer[TIMER_INDEX] = showTime

			end
		end
	end

	lastTick = currentTick
	task.wait()
end

Three parts are created, each stored in their own nested array. The part resides at index 1 of that nested array, while the timer is index 2.

PART_INDEX and TIMER_INDEX represent the indices for the nested lists.

You might encounter variables written in this manner using all capital letters. This is a convention to indicate that these variables are intended to be constants—meaning they won’t change throughout their lifetime. Lua doesn’t technically have constant variables but this is consistent with coding convention only. It is up to you how you wish to style this.


During the for loop, the elapsed time is subtracted from each timer and once the timer has run out a branch statement toggles the visibility and resets it to the proper time.

The only problem now is that all the parts appear/disappear at the same time. We don’t want the full stairway to disappear, just a moving cascade of steps. If each timer is given some constant offset that is dependent on the index of its order, then the cascading effect can be achieved.

You can get the effect by modifying this line:

Lua
local partsList = {{part1, showTime}, {part2, showTime}, {part3, showTime}}

To:

Lua
local partsList = {{part1, showTime+0.2}, {part2, showTime+0.4}, {part3, showTime+0.6}}

Once you’ve added that, you now have everything you need to create the full script. Before viewing the full script, see if you can integrate the two.

I also added an ‘origin’ variable, which will allow the steps to placed at some location other than (0, 0, 0) and then cleaned up the code a bit.

Lua
local stepsList = {}

local folder = Instance.new("Folder")
folder.Parent = game.Workspace

local origin = Vector3.new()

local targetHeight = 1000

local stepDepthTarget = 2
local stepWidth = 10
local stepHeight = 1

local minorDiameter = 45
local radius = minorDiameter / 2

local showLength = 8
local hideLength = 1.5
local hideDelay = 0.075

local minorCircumference = math.pi * minorDiameter --circumference=2*pi*r
local majorCircumference = math.pi * (minorDiameter + stepWidth)

local stepsPerCircle = minorCircumference / stepDepthTarget
local angleIncrement = 360 / stepsPerCircle

local stepDepth = majorCircumference / stepsPerCircle
local stepSize = Vector3.new(stepWidth, stepHeight, stepDepth)

local numberOfSteps = targetHeight / stepHeight

for i = 1, numberOfSteps do
	local part = Instance.new("Part")
	part.CFrame = CFrame.Angles(0, math.rad(i * angleIncrement), 0) * CFrame.new(radius, stepHeight * i, 0) + origin
	part.Size = stepSize
	part.Anchored = true
	part.BottomSurface = Enum.SurfaceType.Smooth
	part.TopSurface = Enum.SurfaceType.Smooth
	part.BrickColor = BrickColor.random()

	part.Parent = folder
	stepsList[i] = {part, i * hideDelay + showLength}
end

local lastTick = tick()

local PART_INDEX = 1
local TIMER_INDEX = 2

while true do
	local et = tick() - lastTick
	lastTick = tick()

	for _, list in ipairs(stepsList) do
		local timeLeft = list[TIMER_INDEX] - et
		local part = list[PART_INDEX]

		list[TIMER_INDEX] = timeLeft

		if list[TIMER_INDEX] <= 0 then

			part.CanCollide = not part.CanCollide

			if part.CanCollide then
				list[TIMER_INDEX] = showLength
				part.Transparency = 0.0
			else
				list[TIMER_INDEX] = hideLength
				part.Transparency = 1.0
			end
		end
	end

	task.wait()
end

You can modify the variables to see how each affect the stairs and game.


Challenge

As it currently is written, the steps toggle on/off at regular intervals. As a challenge, modify the code such that during the cascade, some parts remain visible so players can utilize unaffected steps to keep from falling back down.