Do you like my tutorials?

Then consider supporting me on Ko-fi

Talking about Stairs game, 3D, Game development, Godot Engine and HTML5.

Two of the most important features to add to a game is the way the game starts and the way it ends. In this sixth step of the creation of a Stairs game using Godot engine, we’ll see how to make the game start and end with style.

First, let’s make a small recap of the series:

In first step, we built an endless staircase.

In second step we added spikes and assigned materials to the meshes.

In third step we added a bouncing ball only using trigonometry.

In fourth step we added mouse control to the ball and added some more spikes.

In fifth step a better way to control the ball was added, and we also checked for collision between ball and spikes, and last but not least we checked for the ball to fall down a step.

Now, we are about to see how to make the game start with some style, waiting for user input.

We need to make the ball a bit bigger, so select Ball in Scene panel.

Now in Inspector panel set Radius = 1.5 and Height = 3.

Next thing to do is to make the game start, and the ball bounce, only when the player presses the mouse.

We need to introduce game states, so we can know how the game should work in each state. At the moment we need a state waiting for player input, and a state playing the game.

Let’s change Spatial.gd script adding a couple of lines:

extends Spatial

# state value used when the game is waiting for player
const WAITING_FOR_PLAYER = 0

# state value used when the game is playing 
const PLAYING = 1

# amount of steps we want in the stair
var steps = 15

# array to store all steps
var stepsArray = []

# at the beginning of the game, the game state is "waiting for player"
var gameState = WAITING_FOR_PLAYER

# Called when the node enters the scene tree for the first time.
func _ready() :
	
	# get Step node
	var stepNode = get_node('Step')
	
	# insert the step in steps array
	stepsArray.append(stepNode)
	
	# this is the height of the step, determined according to mesh size
	var deltaY = stepNode.mesh.size.y

	# this is the depth of the step, determined according to mesh size
	var deltaZ = -stepNode.mesh.size.z
	
	# a for loop going from 1 to steps - 1
	for i in range (1, steps) :
		
		# duplicate newStep node
		var newStep = stepNode.duplicate()
		
		#insert the step in steps array
		stepsArray.append(newStep)
		
		# move the duplicated step along y axis
		newStep.translation.y = deltaY * i
		
		# move the duplicated step along z azis
		newStep.translation.z = deltaZ * i
		
		# add the step to the scene
		add_child(newStep)

Now, we need steps to move only if gameState is set to PLAYING, so let’s add a line to Step.gd script:

extends MeshInstance

# stair speed
var speed = 3

# variable to store y/z ratio
var yzRatio

# variable to store the amount of steps
var steps

# initialize the random number generator
var rnd = RandomNumberGenerator.new()

# maximum amount of spikes on each step
var maxSpikes = 4

# Called when the node enters the scene tree for the first time.
func _ready() :
	
	# set a time-based seed for random number generator
	rnd.randomize()
	
	# determine y/z ratio
	yzRatio = mesh.size.y / mesh.size.z
	
	# get steps value from Spatial node
	steps = get_node('/root/Spatial').get('steps')
	
	# custom function to set the spikes
	setSpikes()
	
# This function position enables some random spikes
func setSpikes() :
	
	# loop from 0 to 6
	for i in 7 :
		
		# set i-th child of the step (the i-th spike) to invisible
		get_child(i).visible = false
	
	# loop from 0 to maxSpikes - 1
	for i in maxSpikes :
		
		# toss a number from 0 to 6		
		var randomSpike = rnd.randi_range(0, 6)

		# set the randomSpike-th child of the step to visible
		get_child(randomSpike).visible = true

# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta) :
	
	# check if gameState is "PLAYING"
	# the $ symbol is a shortcut for get_node
	if $'/root/Spatial'.gameState == $'/root/Spatial'.PLAYING :
	
		# move the step along y axis according to speed and elapsed time
		translation.y -= delta * speed
		
		# move the step along z axis according to speed, elapsed time and y/z ratio
		# y/z ratio is necessary because y and z speed cannot be the same as steps
		# do not have the same size so the stair does not have a 45 degrees angle
		translation.z += delta * speed / yzRatio
		
		# is z position greater than mesh z size, making the step to be outside camera eye?
		if (translation.z > mesh.size.z) :
			
			# move the step along y axis as if it were the highest step
			translation.y += steps * mesh.size.y
			
			# move the step along z axis as if it were the furthest step
			translation.z += steps * mesh.size.z * - 1
			
			# set random spikes
			setSpikes()

Although we only added a line, the whole script inside _process function is executed only if the if condition is true.

And same thing goes for the ball, which needs to move only if gameState is set to PLAYING, and of course we also need to set gameState to PLAYING once the player presses the mouse button, so we need to change a bit Ball.gd script too:

extends MeshInstance

# ball starting step. 0: first step, 1: second step, and so on
var ballStartingStep = 1

# jump height, should be higher than step height
var jumpHeight = 5

# here we'll store the jump time, that is the time required for a step to take the place of another
var jumpTime

# here we'll store the amount of time the ball is in play
var ballTime

# we need to store ball starting y position to determine its y position when it's jumping
var ballY

# virtual pax X position
var virtualPadX

# variable to save ball x position when we start acting on the trackpad
var ballPositionX

# ration between movement on the screen (pixels) and movement in game (units)
var virtualPadRatio = 0.1

# ball movement limit, beyond this limit the ball falls down
var ballLimit

# is the ball falling off down a step?
var ballFallingDown

# step the ball is about to land on
var nextStep

# Called when the node enters the scene tree for the first time.
func _ready() :
	
	# ball is not falling down at the moment
	ballFallingDown = false
	
	# when the virtual pad is not active, we set it to -1
	virtualPadX = -1
	
	# get Step reference
	var step = get_node('/root/Spatial/Step')
	
	# set ball limit to half mesh size
	ballLimit = step.mesh.size.x / 2
	
	# jump time, in seconds, is step height divided by step speed
	jumpTime = step.mesh.size.y / step.get('speed') * 1000
	
	# ballTime starts at zero
	ballTime = 0
	
	# determine ball y position according to step size and starting step
	ballY = step.mesh.size.y * ballStartingStep + step.mesh.size.y / 2 + mesh.radius
	
	# move the ball to ballY position
	translation.y = ballY
	
	# determine ball z position according to step size and starting step
	translation.z = -step.mesh.size.z * ballStartingStep
	
# called at every inout evenet
func _input(event) :
	
	# is the event a mouse button?
	if event is InputEventMouseButton :
		
		# did we press the mouse button?
		if (event.pressed) :
			
			# set game state to "playing"
			$'/root/Spatial'.gameState = $'/root/Spatial'.PLAYING
				
			# set virtual pad to x mouse position
			virtualPadX = event.position.x
			
			# save current ball x position
			ballPositionX = translation.x
	
		# in this case, we released the mouse button
		else :
			
			# set virtual pad to -1 again
			virtualPadX = -1
		
# called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta) :
	
	# check if gameState is "PLAYING"
	if $'/root/Spatial'.gameState == $'/root/Spatial'.PLAYING :
	
		# if next step is not defined yet...
		if nextStep == null :
			
			# next step is... actually next step!
			nextStep = get_node('/root/Spatial').get('stepsArray')[ballStartingStep + 1]
		
		# is virtual pad different than -1
		# that means: are we using the virtual pad?
		# also, is the ball not falling down?
		if virtualPadX != -1 and !ballFallingDown :
			
			# get the viewport
			var viewport = get_viewport()
		
			# get mouse x position
			var mouseX = viewport.get_mouse_position().x
			
			# set ball x position according to virtual pad, current mouse position and virtual pad ratio
			translation.x = ballPositionX + (mouseX - virtualPadX) * virtualPadRatio
		
		# increase ballTime adding delta to it
		ballTime += delta * 1000
		
		# if ballTime is greater or equal than jump time...
		if (ballTime >= jumpTime) :
			
			# get main node
			var mainNode = get_node('/root/Spatial')
			
			# increase ball starting step
			ballStartingStep = ballStartingStep + 1
			
			# get next step, where 
			nextStep = mainNode.get('stepsArray')[(ballStartingStep + 1)  % mainNode.get('steps')]
			
			# subtract jumpTime to ballTime
			ballTime -= jumpTime
			
			# if ball x position goes beyond limit...
			if abs(translation.x) > ballLimit :
				
				# the ball falls down
				ballFallingDown = true
		
		# ratio ranges from 0 (ball at the beginning of jump time) to 1 (ball at the end of jump time)
		var ratio = ballTime / jumpTime
		
		# if the ball is in play (not falling down) ...
		if !ballFallingDown :
		
			# move the ball to y position equal to sin of ratio * PI multiplied by jump height
			translation.y = ballY + sin(ratio * PI) * jumpHeight
			
			# loop through all spikes in current step
			for spike in nextStep.get_children() :
				
				# is the spike visible?
				if spike.visible :
					
					# get spike global position
					var spikeGlobalPosition = spike.global_transform.origin
					
					# get ball global position
					var ballGlobalPosition = global_transform.origin
					
					# get spike tip position, which is located 1/2 height above spike global position
					var spikeTip = Vector3(spikeGlobalPosition.x, spikeGlobalPosition.y + spike.mesh.height / 2, spikeGlobalPosition.z)
					
					# get the distance between ball center and spike tip
					var distance = spikeTip.distance_to(ballGlobalPosition)
					
					# if the distance is lower than ball radius...
					if distance < mesh.radius :
						
						# make the ball fall down
						ballFallingDown = true
					
		# if the ball is falling down...
		else :
			
			# move the ball down at high speed
			translation.y -= delta * 30
			
			# if the ball falls down too much...
			if translation.y < -20 :
				
				# restart the game
				get_tree().reload_current_scene()

The concept is the same applied to Step.gd script: the ball moves only if gameState is PLAYING, but inside this script we are also able to update gameState value if the player presses the mouse.

Look at the game now:

Now the game is paused until player presses the mouse.

We also do not want spikes to appear on first steps. The idea is not to make them visible on a step if the index of such step is lower than or equal to ballStartingStep value.

Let’s add another couple of lines to Step.gd:

extends MeshInstance

# stair speed
var speed = 3

# variable to store y/z ratio
var yzRatio

# variable to store the amount of steps
var steps

# initialize the random number generator
var rnd = RandomNumberGenerator.new()

# maximum amount of spikes on each step
var maxSpikes = 4

# Called when the node enters the scene tree for the first time.
func _ready() :
	
	# set a time-based seed for random number generator
	rnd.randomize()
	
	# determine y/z ratio
	yzRatio = mesh.size.y / mesh.size.z
	
	# get steps value from Spatial node
	steps = get_node('/root/Spatial').get('steps')
	
	# custom function to set the spikes
	# the argument is true if the amount of steps placed is greater than or equal to ball starting step
	setSpikes($'/root/Spatial'.stepsArray.size() > $'/root/Spatial/Ball'.ballStartingStep + 1)
	
# This function position enables some random spikes
# visibleSpikes tells us if this step should have visible spikes
func setSpikes(visibleSpikes) :
	
	# loop from 0 to 6
	for i in 7 :
		
		# set i-th child of the step (the i-th spike) to invisible
		get_child(i).visible = false
	
	# if spikes need to be visible...
	if visibleSpikes :
		
		# loop from 0 to maxSpikes - 1
		for i in maxSpikes :
			
			# toss a number from 0 to 6		
			var randomSpike = rnd.randi_range(0, 6)

			# set the randomSpike-th child of the step to visible
			get_child(randomSpike).visible = true

# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta) :
	
	# check if gameState is "PLAYING"
	# the $ symbol is a shortcut for get_node
	if $'/root/Spatial'.gameState == $'/root/Spatial'.PLAYING :
	
		# move the step along y axis according to speed and elapsed time
		translation.y -= delta * speed
		
		# move the step along z axis according to speed, elapsed time and y/z ratio
		# y/z ratio is necessary because y and z speed cannot be the same as steps
		# do not have the same size so the stair does not have a 45 degrees angle
		translation.z += delta * speed / yzRatio
		
		# is z position greater than mesh z size, making the step to be outside camera eye?
		if (translation.z > mesh.size.z) :
			
			# move the step along y axis as if it were the highest step
			translation.y += steps * mesh.size.y
			
			# move the step along z axis as if it were the furthest step
			translation.z += steps * mesh.size.z * - 1
			
			# set random spikes, with visible spikes
			setSpikes(true)

And now the game works this way:

As you can see, first steps are clear.

Now we want the ball to die with style when it hits a spike. Let’s shake the camera a bit, then restart the game.

From Scene panel, right click on Camera then select Attach Script.

Don’t worry about the alert and click on Create.

Now you should have a new script called Camera.gd.

As usual, we need to replace the default script with our custom script:

extends Camera

# current camera shakes so far
var currentShakes = 0

# total amount of shakes we want
var wantedShakes = 30

# method to shake the camera
func shake() :
	
	# set camera rotation, in radians. 
	# that -0.5236 means -30 degrees
	set_rotation(Vector3(-0.5236 + rand_range(-0.01, 0.01), rand_range(-0.01, 0.01), rand_range(-0.01, 0.01)))
	
	# increase the amount of total shakes
	currentShakes += 1
	
	# are we done with the shakes?
	if currentShakes == wantedShakes :
		
		# restart the game
		get_tree().reload_current_scene()

We also have to know when the player dies, creating a new game state in Spatial.gd script:

extends Spatial

# state value used when the game is waiting for player
const WAITING_FOR_PLAYER = 0

# state value used when the game is playing 
const PLAYING = 1

# state value used when the player is dying
const DYING = 2

# amount of steps we want in the stair
var steps = 15

# array to store all steps
var stepsArray = []

# at the beginning of the game, the game state is "waiting for player"
var gameState = WAITING_FOR_PLAYER

# Called when the node enters the scene tree for the first time.
func _ready() :
	
	# get Step node
	var stepNode = get_node('Step')
	
	# insert the step in steps array
	stepsArray.append(stepNode)
	
	# this is the height of the step, determined according to mesh size
	var deltaY = stepNode.mesh.size.y

	# this is the depth of the step, determined according to mesh size
	var deltaZ = -stepNode.mesh.size.z
	
	# a for loop going from 1 to steps - 1
	for i in range (1, steps) :
		
		# duplicate newStep node
		var newStep = stepNode.duplicate()
		
		#insert the step in steps array
		stepsArray.append(newStep)
		
		# move the duplicated step along y axis
		newStep.translation.y = deltaY * i
		
		# move the duplicated step along z azis
		newStep.translation.z = deltaZ * i
		
		# add the step to the scene
		add_child(newStep)

And finally we can edit Ball.gd to handle DYING state:

extends MeshInstance

# ball starting step. 0: first step, 1: second step, and so on
var ballStartingStep = 1

# jump height, should be higher than step height
var jumpHeight = 5

# here we'll store the jump time, that is the time required for a step to take the place of another
var jumpTime

# here we'll store the amount of time the ball is in play
var ballTime

# we need to store ball starting y position to determine its y position when it's jumping
var ballY

# virtual pax X position
var virtualPadX

# variable to save ball x position when we start acting on the trackpad
var ballPositionX

# ration between movement on the screen (pixels) and movement in game (units)
var virtualPadRatio = 0.1

# ball movement limit, beyond this limit the ball falls down
var ballLimit

# is the ball falling off down a step?
var ballFallingDown

# step the ball is about to land on
var nextStep

# Called when the node enters the scene tree for the first time.
func _ready() :
	
	# ball is not falling down at the moment
	ballFallingDown = false
	
	# when the virtual pad is not active, we set it to -1
	virtualPadX = -1
	
	# get Step reference
	var step = get_node('/root/Spatial/Step')
	
	# set ball limit to half mesh size
	ballLimit = step.mesh.size.x / 2
	
	# jump time, in seconds, is step height divided by step speed
	jumpTime = step.mesh.size.y / step.get('speed') * 1000
	
	# ballTime starts at zero
	ballTime = 0
	
	# determine ball y position according to step size and starting step
	ballY = step.mesh.size.y * ballStartingStep + step.mesh.size.y / 2 + mesh.radius
	
	# move the ball to ballY position
	translation.y = ballY
	
	# determine ball z position according to step size and starting step
	translation.z = -step.mesh.size.z * ballStartingStep
	
# called at every inout evenet
func _input(event) :
	
	# is the event a mouse button?
	if event is InputEventMouseButton :
		
		# did we press the mouse button and are we still alive?
		if (event.pressed and $'/root/Spatial'.gameState != $'/root/Spatial'.DYING) :
			
			# set game state to "playing"
			$'/root/Spatial'.gameState = $'/root/Spatial'.PLAYING
				
			# set virtual pad to x mouse position
			virtualPadX = event.position.x
			
			# save current ball x position
			ballPositionX = translation.x
	
		# in this case, we released the mouse button
		else :
			
			# set virtual pad to -1 again
			virtualPadX = -1
		
# called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta) :
	
	# check if gameState is "dying"
	if $'/root/Spatial'.gameState == $'/root/Spatial'.DYING :
		
		# call camera's shake method
		$'/root/Spatial/Camera'.shake()
	
	# check if gameState is "PLAYING"
	if $'/root/Spatial'.gameState == $'/root/Spatial'.PLAYING :
	
		# if next step is not defined yet...
		if nextStep == null :
			
			# next step is... actually next step!
			nextStep = get_node('/root/Spatial').get('stepsArray')[ballStartingStep + 1]
		
		# is virtual pad different than -1
		# that means: are we using the virtual pad?
		# also, is the ball not falling down?
		if virtualPadX != -1 and !ballFallingDown :
			
			# get the viewport
			var viewport = get_viewport()
		
			# get mouse x position
			var mouseX = viewport.get_mouse_position().x
			
			# set ball x position according to virtual pad, current mouse position and virtual pad ratio
			translation.x = ballPositionX + (mouseX - virtualPadX) * virtualPadRatio
		
		# increase ballTime adding delta to it
		ballTime += delta * 1000
		
		# if ballTime is greater or equal than jump time...
		if (ballTime >= jumpTime) :
			
			# get main node
			var mainNode = get_node('/root/Spatial')
			
			# increase ball starting step
			ballStartingStep = ballStartingStep + 1
			
			# get next step, where 
			nextStep = mainNode.get('stepsArray')[(ballStartingStep + 1)  % mainNode.get('steps')]
			
			# subtract jumpTime to ballTime
			ballTime -= jumpTime
			
			# if ball x position goes beyond limit...
			if abs(translation.x) > ballLimit :
				
				# the ball falls down
				ballFallingDown = true
		
		# ratio ranges from 0 (ball at the beginning of jump time) to 1 (ball at the end of jump time)
		var ratio = ballTime / jumpTime
		
		# if the ball is in play (not falling down) ...
		if !ballFallingDown :
		
			# move the ball to y position equal to sin of ratio * PI multiplied by jump height
			translation.y = ballY + sin(ratio * PI) * jumpHeight
			
			# loop through all spikes in current step
			for spike in nextStep.get_children() :
				
				# is the spike visible?
				if spike.visible :
					
					# get spike global position
					var spikeGlobalPosition = spike.global_transform.origin
					
					# get ball global position
					var ballGlobalPosition = global_transform.origin
					
					# get spike tip position, which is located 1/2 height above spike global position
					var spikeTip = Vector3(spikeGlobalPosition.x, spikeGlobalPosition.y + spike.mesh.height / 2, spikeGlobalPosition.z)
					
					# get the distance between ball center and spike tip
					var distance = spikeTip.distance_to(ballGlobalPosition)
					
					# if the distance is lower than ball radius...
					if distance < mesh.radius :
						
						# ...DIE!
						$'/root/Spatial'.gameState = $'/root/Spatial'.DYING
					
		# if the ball is falling down...
		else :
			
			# move the ball down at high speed
			translation.y -= delta * 30
			
			# if the ball falls down too much...
			if translation.y < -20 :
				
				# restart the game
				get_tree().reload_current_scene()

Look at the final result:

Now when you hit a spike, you will die with style. During next step we’ll see how to adjust camera, background and settings to turn this prototype into a portrait mobile game, meanwhile download the source code of the entire project.

Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.