Build a 3D HTML5 game like “Stairs” using Godot – Step 5: better way to control the ball, check for collisions with spikes and check for the ball to fall down the steps

Read all posts about "" game

Here we go with the 5th part of the tutorial series about the creation of a Stairs game using Godot. The prototype is getting more and more playable step after step, so let’s see a small recap:

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.

Now it’s time to improve ball control introducing some kind of virtual trackpad, and check if the ball falls off the stairs or hits a spike.

We will only act on the code, so there is no need to modify the scene.

First, let’s adds a virtual trackpad. We have to edit Ball.gd file, first of all removing ballRange variable since we don’t use it anymore.

Then, the idea is: the ball does not move left or right until player clicks on the canvas. Then, the x coordinate where the player clicked becomes the origin, and as long as the player drags, we move the ball to the left or to the right until the player releases the button.

Look at the script, with new lines highlighted:

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

# Called when the node enters the scene tree for the first time.
func _ready() :
	
	# when the virtual pad is not active, we set it to -1
	virtualPadX = -1
	
	# get Step reference
	var step = get_node('/root/Spatial/Step')
	
	# 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 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) :
	
	# is virtual pad different than -1
	# that means: are we using the virtual pad?
	if virtualPadX != -1 :
		
		# 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 assing delta to it
	ballTime += delta * 1000
	
	# if ballTime is greater or equal than jump time...
	if (ballTime >= jumpTime) :
		
		# subtract jumpTime to ballTime
		ballTime -= jumpTime
	
	# ratio ranges from 0 (ball at the beginning of jump time) to 1 (ball at the end of jump time)
	var ratio = ballTime / jumpTime
	
	# move the ball to y position equal to sin of ratio * PI multiplied by jump height
	translation.y = ballY + sin(ratio * PI) * jumpHeight

And now look at the result:

Click and drag the mouse to move the ball. You’ll probably notice the ball now can bounce outside the stair, and this is what we are going to fix now.

We must check, once the ball completes its sine movement, if the x coordinate is inside the step. If not, player won’t be able to control the ball anymore, and we let it fall down.

We still need to edit a bit Ball.gd, look at the new lines:

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

# 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 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) :
	
	# 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) :
		
		# 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
	
	# 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()

Now let’s see the result:

Now, if you land outside the step, the ball will fall down, then the game restarts.

Checking for collisions will be a bit harder, because we won’t be using any physics engine, but we can handle it.

First, we need to store all steps into an array, to give them an easy access from inside the code.

Change Spatial.gd adding these highlighted lines:

extends Spatial

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

# array to store all steps
var stepsArray = []

# 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 all steps are inside stepsArray array.

Now, this is the plan:

1 – Identify the step the ball is about to land on.

2 – For each spike on the step, check if it’s visible.

3 – If a spike is visible, determine the 3D coordinate of spike’s tip. It’s located half spike height above spike’s origin.

4 – Calculate the distance between spike’s tip and ball center.

5 – If the distance is less then ball radius, then we can say the spike is inside the ball.

It’s not the most accurate collision detection, but it’s exactly what we need for a hyper casual game.

Time to edit Ball.gd one more time, look at highlighted lines as usual:

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 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) :
	
	# 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()

And this is the result:

Now we are able to check when the ball hits a spike. This time I just make the ball fall down like it landed outside the step, but at least it works. Next time we’ll see how to properly manage deaths, score and level progression, meanwhile download the source code of the entire project.

Get the most popular Phaser 3 book

Through 202 pages, 32 source code examples and an Android Studio project you will learn how to build cross platform HTML5 games and create a complete game along the way.

Get the book

214 GAME PROTOTYPES EXPLAINED WITH SOURCE CODE
// 1+2=3
// 100 rounds
// 10000000
// 2 Cars
// 2048
// A Blocky Christmas
// A Jumping Block
// A Life of Logic
// Angry Birds
// Angry Birds Space
// Artillery
// Astro-PANIC!
// Avoider
// Back to Square One
// Ball Game
// Ball vs Ball
// Ball: Revamped
// Balloon Invasion
// BallPusher
// Ballz
// Bar Balance
// Bejeweled
// Biggification
// Block it
// Blockage
// Bloons
// Boids
// Bombuzal
// Boom Dots
// Bouncing Ball
// Bouncing Ball 2
// Bouncy Light
// BoxHead
// Breakout
// Bricks
// Bubble Chaos
// Bubbles 2
// Card Game
// Castle Ramble
// Chronotron
// Circle Chain
// Circle Path
// Circle Race
// Circular endless runner
// Cirplosion
// CLOCKS - The Game
// Color Hit
// Color Jump
// ColorFill
// Columns
// Concentration
// Crossy Road
// Crush the Castle
// Cube Jump
// CubesOut
// Dash N Blast
// Dashy Panda
// Deflection
// Diamond Digger Saga
// Don't touch the spikes
// Dots
// Down The Mountain
// Drag and Match
// Draw Game
// Drop Wizard
// DROP'd
// Dudeski
// Dungeon Raid
// Educational Game
// Elasticity
// Endless Runner
// Erase Box
// Eskiv
// Farm Heroes Saga
// Filler
// Flappy Bird
// Fling
// Flipping Legend
// Floaty Light
// Fuse Ballz
// GearTaker
// Gem Sweeper
// Globe
// Goat Rider
// Gold Miner
// Grindstone
// GuessNext
// Helicopter
// Hero Emblems
// Hero Slide
// Hexagonal Tiles
// HookPod
// Hop Hop Hop Underwater
// Horizontal Endless Runner
// Hundreds
// Hungry Hero
// Hurry it's Christmas
// InkTd
// Iromeku
// Jet Set Willy
// Jigsaw Game
// Knife Hit
// Knightfall
// Legends of Runeterra
// Lep's World
// Line Rider
// Lumines
// Magick
// MagOrMin
// Mass Attack
// Math Game
// Maze
// Meeblings
// Memdot
// Metro Siberia Underground
// Mike Dangers
// Mikey Hooks
// Nano War
// Nodes
// o:anquan
// One Button Game
// One Tap RPG
// Ononmin
// Pacco
// Perfect Square!
// Perfectionism
// Phyballs
// Pixel Purge
// PixelField
// Planet Revenge
// Plants Vs Zombies
// Platform
// Platform game
// Plus+Plus
// Pocket Snap
// Poker
// Pool
// Pop the Lock
// Pop to Save
// Poux
// Pudi
// Pumpkin Story
// Puppet Bird
// Pyramids of Ra
// qomp
// Quick Switch
// Racing
// Radical
// Rebuild Chile
// Renju
// Rise Above
// Risky Road
// Roguelike
// Roly Poly
// Run Around
// Rush Hour
// SameGame
// SamePhysics
// Security
// Serious Scramblers
// Shrink it
// Sling
// Slingy
// Snowflakes
// Sokoban
// Space Checkers
// Space is Key
// Spellfall
// Spinny Gun
// Splitter
// Spring Ninja
// Sproing
// Stabilize!
// Stack
// Stairs
// Stick Hero
// String Avoider
// Stringy
// Sudoku
// Super Mario Bros
// Surfingers
// Survival Horror
// Talesworth Adventure
// Tetris
// The Impossible Line
// The Moops - Combos of Joy
// The Next Arrow
// Threes
// Tic Tac Toe
// Timberman
// Tiny Wings
// Tipsy Tower
// Toony
// Totem Destroyer
// Tower Defense
// Trick Shot
// Tunnelball
// Turn
// Turnellio
// TwinSpin
// vvvvvv
// Warp Shift
// Way of an Idea
// Whack a Creep
// Wheel of Fortune
// Where's my Water
// Wish Upon a Star
// Word Game
// Wordle
// Worms
// Yanga
// Yeah Bunny
// Zhed
// zNumbers