Making 3D Endless Runner Game Part 2

infinite runner like temple run tutorial preview

Our target is to make procedural world, spawning of coins, obstacles and environment. Part 1 is here.

Making Infinite Scrolling Level

We will spawn objects some distance away from player, and those objects will move backwards. This is it.

The exact type of objects depend on the game. In subway surfers, we have different kinds of obstacles & we can slide, jump over or simply dodge them. In our game, we only have rocks that we can jump over or simply dodge.

As stated in part 1, we have 3 lanes, and all objects will be spawned only on those 3 lanes. It means, no matter the z-axis position of an object, it must always remain in one of the three x-axis positions [-2, 0, 2] (each position corresponds to a lane).

three tracks of endless runner game

Spawning Road

In Level.gd:

extends Node

@export var player: CharacterBody3D
@export var coin_spawn_timer: Timer
@export var obstacle_spawn_timer: Timer
@export var road_spawn_timer: Timer # Set 2 second timeout


@export var coin: PackedScene

@export var rock:  PackedScene

@export var road: PackedScene


var startz: float = -50.0 # Spawn this away in forward direction
var road_spawnx: Array = [-2, 0, 2] # Spawn only in these x positions



func _ready():
	var x = 0
	var y = 0
	var z = 5
	
	# Spawn road first (after this, spawn after timeout)
	var road_asset = road.instantiate()
	add_child(road_asset)
	road_asset.global_transform.origin = Vector3(0, 0, startz)




func _on_road_spawn_timer_timeout():
	var road_asset = road.instantiate()
	add_child(road_asset)
	road_asset.global_transform.origin = Vector3(
		0,
		0,
		startz
	)

We use a timer to spawn the road after every 2 seconds. We connect the timeout signal to a function which instances a road mesh and places it at some predefined distance away from player. The road mesh has a script attached to make it move backwards with time.

And the script also has its own timer of 5 or 6 seconds to remove the object as after this time, this object will be passed by the player and now is behind the player.

This is the scene of a road (this scene will be instanced to place it on level as a road):

snowy road for endless runner

Moving backwards script

Attach this script to all objects (including road) that should move backward (to simulate player moving forward).

In object’s own script:

extends Node3D

var timer: Timer = Timer.new()

func _ready():
	timer.wait_time = 5
	timer.autostart = true
	timer.timeout.connect(timer_timeout)
	add_child(timer)


func _process(delta):
	global_translate(Vector3(0, 0, 0.25))


func timer_timeout():
	queue_free()

Spawning Rock Obstacle

In Level.gd:


func _on_spawn_obstacle_timer_timeout():
	randomize()
	spawn_obstacle_timer.wait_time = randi() % 5 + 1
	
	var random_line_num = randi() % 3
	var prev_rand_line_n = null
	
	var line_count: int = randi() % 4 + 1
	
	for i in line_count:
		while (prev_rand_line_n != null and prev_rand_line_n == random_line_num):
				random_line_num = randi() % 3
		prev_rand_line_n = random_line_num

		var rock_inst = rock.instantiate()
		add_child(rock_inst)
	
		rock_inst.global_transform.origin = Vector3(
			road_spawnx[random_line_num],
			0.0,
			startz
		)
		rock_inst.rotation_degrees.y = randf_range(0, 360)

Obstacles are also separate scenes that get spawned after a timeout. Their initial position must be some units forward to player and their x-axis position must be on any of [-2, 0, 2]. They have same ‘moving-backwards’ script attached to move them and free them after they have crossed the player and are now behind him.

Example obstacle (rock) in our implementation:

Rock obstacle, once touched, should kill the player. So attach an Area3D as child of rock. And connect its signal to function so if player hits it, it kills the player:

In object’s own script:

# Assuming player is added to group 'players'
func _on_area_3d_body_entered(body):
	if body in get_tree().get_nodes_in_group("players"):
		player.die() # Implement it yourself

Simplest implementation of player.die() can be just close the game or pause the game using get_tree().paused = true in Godot 4. For a real game, we can play death animation and go back to main menu.

Spawning Coins

In Level.gd:

func _on_coin_spawn_timer_timeout():
	randomize()
	coin_spawn_timer.wait_time = randi() % 5 + 1 
	
	var random_line_num = randi() % 3
	var prev_rand_line_n = null
	
	var line_count: int = randi() % 4 + 1
	
	for i in line_count:
		while (prev_rand_line_n != null and prev_rand_line_n == random_line_num):
				random_line_num = randi() % 3
		prev_rand_line_n = random_line_num

		for n in randf_range(4, 10):
		
			var coin_inst: MeshInstance3D = coin.instantiate()
	
			add_child(coin_inst)
	
			coin_inst.global_transform.origin = Vector3(
				road_spawnx[random_line_num],
				1.0,
				startz + i * 2.5 # set distance between coins
			)

Coins are also similar objects, but the difference is, once we hit them, they get removed queue_free() and player.coin_count += 1. Rest is same. one thing unique in coins is that they rotate, which can be simulated by adding this line in _process(delta) of coin scene script:

In object’s own script:

func _process(delta):
	rotate_y(5 * delta)
coin for video game

More

My implementation of endless runner is quite basic, it can be extended if intention is to publish the game to play store or steam.

Feel free to ask if you have any questions!

Thank you for reading <3

Leave a Reply

Your email address will not be published. Required fields are marked *