Lesson 31

Endless Runner

One key, one track that never ends. The warrior never moves an inch: the world comes at you, faster every second, until it wins.

Loading the game engine. This takes a moment the first time.

Play it first, then read this. Same warrior, and this time you have ONE key: Space. There is nothing else to press, because in this style you do not decide where to go. You only decide when to jump.

WHAT TO NOTICE WHILE YOU PLAY

THE PLAYER
Watch the warrior's x position. It never changes. You are running on the spot, and the world is what moves. Every endless runner ever made is this trick.
NO END
There is no flag, no exit and no last level. The only question the game asks is how far, so the score IS the finish line.
IT SPEEDS UP
The speed readout climbs the whole time, and the gap tightens until landing and jumping again is one motion. A game you cannot lose has no reason to have a score.
ALWAYS FAIR
The gap between blocks is measured in SECONDS, not pixels, and it never drops below one jump. Nothing that arrives is impossible. It only gets harder to read in time.

WHY THE GAP IS IN SECONDS

A jump takes 0.72 seconds from the ground and back, always, because gravity and jump strength never change. So the game spaces blocks by TIME and refuses to go under 0.85 seconds. As the world speeds up that same 0.85 seconds becomes a much wider gap in pixels, which is exactly what keeps it possible. Space them by pixels instead and the game turns unwinnable the moment it gets fast. What is left at the tightest spacing is a tenth of a second on the ground between one jump and the next, which is where a person runs out and a machine does not.

HOW THE WORLD MOVES

Nothing here is spawned already moving. _physics_process() walks the obstacles list and the ground stripes and slides every one of them left by "scroll_speed * delta". One speed, one loop, so the whole world always agrees about how fast it is going. Anything that runs off the left edge is deleted, which is why a five minute run does not end up holding a thousand blocks.

New code in this lesson

None of the earlier lessons use these. Each one shows the line where it appears here.

range()

Counts through whole numbers: range(3) is 0, 1, 2. Given a start, a stop and a step of -1, it counts backward.

game.gd
for i: int in range(obstacles.size() - 1, -1, -1):

The scenes

■ UI node · ■ 2D node · % unique name · .tscn instanced scene

game.tscn
  • Game Node2D game.gd
  • Ground StaticBody2D block.tscn
  • World Node2D
  • Player CharacterBody2D player.tscn
  • HUD CanvasLayer hud.tscn
hud.tscn
  • HUD CanvasLayer hud.gd
  • Readouts VBoxContainer
  • DistanceLabel Label %
  • SpeedLabel Label %
  • BestLabel Label %
  • ResultLabel Label %
  • Hint Label
player.tscn
  • Player CharacterBody2D player.gd
  • Sprite2D
  • CollisionShape2D
block.tscn
  • Block StaticBody2D
  • Polygon2D
  • CollisionPolygon2D
obstacle.tscn
  • Obstacle Area2D
  • Polygon2D
  • CollisionShape2D

FileSystem

  • game.tscn
  • game.gd
  • hud.tscn
  • hud.gd
  • player.tscn
  • player.gd
  • block.tscn
  • obstacle.tscn

The code

extends Node2D

# Where the warrior stands. It never changes, and that is the whole style.
const PLAYER_X: float = 260.0
# The top of the ground, in pixels down from the top of the screen.
const GROUND_Y: float = 520.0
# Just off the right edge, so a block is never seen popping into being.
const SPAWN_X: float = 1240.0

const BASE_SPEED: float = 340.0
## How much faster the world gets every second, in pixels per second.
@export var speed_ramp: float = 14.0

# The gap between blocks, in seconds. It shrinks as the run goes on, but
# never past MIN_GAP, which is longer than one jump takes.
const START_GAP: float = 1.6
const MIN_GAP: float = 0.85
const GAP_TIGHTEN: float = 0.02

# How many pixels count as a metre on the readout.
const PIXELS_PER_METRE: float = 40.0

const OBSTACLE := preload("res://lessons/runner_sampler/obstacle.tscn")

var scroll_speed: float = BASE_SPEED
var gap_seconds: float = START_GAP
var spawn_timer: float = 1.2
var distance: float = 0.0
var best_distance: float = 0.0
var running: bool = true

# Every block currently on screen. The level moves them; they have no
# script of their own.
var obstacles: Array[Area2D] = []
# The dashes on the ground. They move exactly like the blocks do, which is
# what makes the running look like running.
var stripes: Array[ColorRect] = []

@onready var player: Player = $Player
@onready var hud: RunnerHUD = $HUD
@onready var world: Node2D = $World

func _ready() -> void:
	player.position = Vector2(PLAYER_X, GROUND_Y - 20.0)
	_build_stripes()
	hud.set_best(0)

func _physics_process(delta: float) -> void:
	if not running:
		return

	_advance_difficulty(delta)

	distance += scroll_speed * delta
	hud.set_run(_metres(distance), int(scroll_speed))

	# THE WHOLE ILLUSION. One loop, one speed, and the player is not in it.
	var step := scroll_speed * delta
	for obstacle: Area2D in obstacles:
		obstacle.position.x -= step
	for stripe: ColorRect in stripes:
		stripe.position.x -= step
		# A stripe that has left the screen is not deleted, it is sent back
		# round to the right. Fourteen stripes can scroll forever.
		if stripe.position.x < -120.0:
			stripe.position.x += 1400.0

	_clear_passed_obstacles()

	spawn_timer -= delta
	if spawn_timer <= 0.0:
		_spawn_obstacle()
		# A little randomness on top of the gap, so the rhythm is not a
		# metronome you can beat with your eyes shut.
		spawn_timer = gap_seconds + randf_range(0.0, 0.35)

# Both difficulty numbers move every frame. The floor under gap_seconds is
# the promise that the game stays possible.
func _advance_difficulty(delta: float) -> void:
	scroll_speed += speed_ramp * delta
	gap_seconds = maxf(MIN_GAP, gap_seconds - GAP_TIGHTEN * delta)

# Make it, fill it in, add_child(). The Enemy Spawner three-step, except
# the level connects to the new block itself instead of the block carrying
# a script to do it.
func _spawn_obstacle() -> void:
	var obstacle: Area2D = OBSTACLE.instantiate()
	obstacle.position = Vector2(SPAWN_X, GROUND_Y - 30.0)
	obstacle.body_entered.connect(_on_obstacle_touched)
	world.add_child(obstacle)
	obstacles.append(obstacle)

func _clear_passed_obstacles() -> void:
	# Walking the list backwards, because removing an entry shuffles every
	# entry after it down one place.
	for i: int in range(obstacles.size() - 1, -1, -1):
		if obstacles[i].position.x < -80.0:
			obstacles[i].queue_free()
			obstacles.remove_at(i)

func _on_obstacle_touched(body: Node2D) -> void:
	if body is Player:
		_end_run()

func _end_run() -> void:
	running = false
	player.running = false
	best_distance = maxf(best_distance, distance)
	hud.set_best(_metres(best_distance))
	hud.show_result("Run over at %d m. Press Space to run again." % _metres(distance))

# Restarting throws the world away and rebuilds it. There is no scene
# reload here, because nothing outside this run needs to survive.
func _restart() -> void:
	for obstacle: Area2D in obstacles:
		obstacle.queue_free()
	obstacles.clear()

	scroll_speed = BASE_SPEED
	gap_seconds = START_GAP
	spawn_timer = 1.2
	distance = 0.0
	running = true

	player.position = Vector2(PLAYER_X, GROUND_Y - 20.0)
	player.velocity = Vector2.ZERO
	player.running = true
	hud.show_result("")

# Space does double duty: it jumps during a run, and starts the next one
# from the results screen. The player script ignores it while stopped.
func _unhandled_input(event: InputEvent) -> void:
	if not running and event.is_action_pressed("jump"):
		_restart()

func _build_stripes() -> void:
	for i: int in 14:
		var stripe := ColorRect.new()
		stripe.color = Color(0.22, 0.24, 0.3)
		stripe.size = Vector2(60, 8)
		stripe.position = Vector2(i * 100.0, GROUND_Y + 26.0)
		world.add_child(stripe)
		stripes.append(stripe)

func _metres(pixels: float) -> int:
	return int(pixels / PIXELS_PER_METRE)