Build a 2D Endless Runner in Godot 4 — Part 3: Game States, Score, and Obstacles

A three-state game loop — IDLE, PLAYING, GAME OVER — wired up with a score counter, a random obstacle spawner that ramps speed, and an auto-reset that needs zero player input.

This is Part 3 of a three-part series. In Part 1 we built the parallax background. In Part 2 we added the player character and camera lock. Now we wire it all together into a complete game: a state machine, a score counter, obstacles that spawn and accelerate, and a loop that resets itself automatically after a game over.


The game loop

Three states, one enum:

IDLE      → player runs (no jump), "PRESS SPACE TO START"
              ↓ Space / tap
PLAYING   → obstacles spawn, score climbs, player can jump
              ↓ player hits obstacle
GAME OVER → "GAME OVER" shown, 2.5s pause
              ↓ timer expires
IDLE      → obstacles cleared, player revived, score reset

No input required to return from GAME OVER to IDLE — the timer handles it. This keeps the game mobile-friendly and removes any "press to restart" friction.


Game Manager

The root Node2D carries game_manager.gd. It owns the state machine, connects the player's died signal, drives the score counter, and delegates spawning to ObstacleSpawner.

## Root scene controller. States: IDLE → PLAYING → GAME_OVER → IDLE.
extends Node2D

enum State { IDLE, PLAYING, GAME_OVER }

@onready var _player: CharacterBody2D = $Player
@onready var _spawner: Node2D         = $ObstacleSpawner
@onready var _score_label: Label      = $UI/ScoreLabel
@onready var _msg_label: Label        = $UI/MsgLabel

var _score: float  = 0.0
var _state: State  = State.IDLE
var _revive_timer: float = 0.0

const REVIVE_DELAY := 2.5

func _ready() -> void:
    _player.died.connect(_on_player_died)
    _enter_idle()

func _enter_idle() -> void:
    _state = State.IDLE
    _score = 0.0
    _score_label.text = "SCORE  00000"
    _msg_label.text = "PRESS SPACE TO START"
    _spawner.clear_obstacles()
    _player.revive(false)       # runs but jump disabled in idle

func _enter_playing() -> void:
    _state = State.PLAYING
    _msg_label.text = ""
    _player.revive(true)        # jump enabled
    _spawner.start()

func _unhandled_input(event: InputEvent) -> void:
    if _state != State.IDLE:
        return
    if event is InputEventKey and event.pressed and not event.echo:
        if event.keycode == KEY_SPACE or event.physical_keycode == KEY_SPACE:
            _enter_playing()
    elif event is InputEventScreenTouch and event.pressed:
        _enter_playing()

func _process(delta: float) -> void:
    match _state:
        State.PLAYING:
            _score += delta * 10.0
            _score_label.text = "SCORE  %05d" % int(_score)
        State.GAME_OVER:
            _revive_timer -= delta
            if _revive_timer <= 0.0:
                _enter_idle()

func _on_player_died() -> void:
    if _state != State.PLAYING:
        return
    _state = State.GAME_OVER
    _spawner.stop()
    _msg_label.text = "GAME OVER"
    _revive_timer = REVIVE_DELAY

Why _unhandled_input instead of _input? Obstacles are Area2D nodes that listen for body collisions, but any node higher in the tree can consume input events first. _unhandled_input only fires if no other node already handled the event, so there's no risk of a stray Area2D swallowing the Space keypress.

Why gate _on_player_died on _state == PLAYING? The player's died signal could theoretically fire during GAME OVER (if there's already an obstacle on screen at the moment of the hit). The guard prevents a double-trigger that would reset the timer prematurely.


Obstacle Spawner

ObstacleSpawner is a plain Node2D with one job: instantiate obstacles off the right edge of the viewport at randomised intervals, and increment speed on each spawn.

## Spawns obstacles at random intervals just off the right edge of the viewport.
## Speed ramps up slightly with each spawn.
extends Node2D

@export var obstacle_scene: PackedScene
@export var ground_y: float = 680.0
@export var interval_min: float = 1.2
@export var interval_max: float = 3.2
@export var base_speed: float = 340.0
@export var speed_ramp: float = 12.0

var _timer: float = 0.0
var _next: float = 2.0
var _active: bool = false
var _speed: float = 0.0

func start() -> void:
    _active = true
    _speed = base_speed
    _timer = 0.0
    _next = randf_range(interval_min, interval_max)

func stop() -> void:
    _active = false

func clear_obstacles() -> void:
    for node in get_tree().get_nodes_in_group("obstacle"):
        node.queue_free()
    _active = false
    _timer = 0.0
    _speed = base_speed

func _process(delta: float) -> void:
    if not _active:
        return
    _timer += delta
    if _timer >= _next:
        _timer = 0.0
        _next = randf_range(interval_min, interval_max)
        _spawn()

func _spawn() -> void:
    if not obstacle_scene:
        return
    var cam := get_viewport().get_camera_2d()
    var right_edge: float = (cam.global_position.x if cam else 640.0) + 720.0
    var obs: Area2D = obstacle_scene.instantiate()
    obs.speed = _speed
    obs.position = Vector2(right_edge, ground_y)
    obs.add_to_group("obstacle")
    get_parent().add_child(obs)
    _speed += speed_ramp

The spawn offset of +720 past the camera centre puts obstacles roughly one full viewport width off-screen to the right. They scroll into view naturally rather than popping in at the edge.

ground_y = 680.0 matches the StaticBody2D floor from Part 2. Obstacles sit at this Y and their sprite is offset upward so their base appears to rest on the ground line.

add_to_group("obstacle") lets clear_obstacles() find every live obstacle via get_nodes_in_group without needing direct references — important because obstacles are added as children of the root scene, not the spawner.


Obstacle scene

Obstacle (Area2D)  ← obstacle.gd
├── Sprite2D       texture=obstacle_rock.png, scale=(1.4,1.4), position=(0,-42)
├── CollisionShape2D  RectangleShape2D(60,76), position=(0,-42)
└── VisibleOnScreenNotifier2D
# obstacle.gd
extends Area2D

@export var speed: float = 340.0

func _ready() -> void:
    body_entered.connect(_on_body_entered)
    $VisibleOnScreenNotifier2D.screen_exited.connect(queue_free)

func _process(delta: float) -> void:
    position.x -= speed * delta

func _on_body_entered(body: Node2D) -> void:
    if body.is_in_group("player"):
        body.on_hit_obstacle()
        queue_free()

VisibleOnScreenNotifier2D.screen_exited frees the obstacle automatically once it scrolls off the left edge. No manual cleanup needed during gameplay — only clear_obstacles() on game reset touches the obstacle list directly.

The sprite and collision shape share position.y = -42 so the obstacle's visual base lands at Y=0 (the node's local origin), which sits exactly on the ground_y = 680 floor.


UI setup

Both labels live in a CanvasLayer so they render at fixed screen positions regardless of camera movement.

UI (CanvasLayer)
├── ScoreLabel (Label)
│     offset_left=20, offset_top=16, offset_right=400, offset_bottom=48
│     font_size=22
│     text="SCORE  00000"
└── MsgLabel (Label)
      offset_top=300, offset_right=1280, offset_bottom=400
      font_size=28
      horizontal_alignment=CENTER

MsgLabel is full viewport width so horizontal_alignment=CENTER actually centres the text. The three strings it shows map directly to the three states:

State MsgLabel
IDLE "PRESS SPACE TO START"
PLAYING ""
GAME_OVER "GAME OVER"

The _can_jump bridge between manager and player

The game manager controls whether the player can jump by passing a boolean to revive():

# idle — player runs as part of the background parallax demo
_player.revive(false)

# playing — player can respond to input
_player.revive(true)

Inside player.gd the jump check honours that flag:

if _can_jump and is_on_floor() and Input.is_action_just_pressed("ui_accept"):
    velocity.y = JUMP_FORCE

This is how the game maintains a live parallax demo in the IDLE state without the player accidentally jumping before the round starts. The fox runs, the background scrolls, and nothing responds to input until the state machine says so.


Full game flow summary

1. Scene loads → _ready() → _enter_idle()
   • Player runs (no jump), parallax scrolls
   • "PRESS SPACE TO START", score = 0

2. Player presses Space → _enter_playing()
   • MsgLabel cleared, jump enabled
   • ObstacleSpawner.start() — first obstacle in 1.2–3.2s

3. Obstacle spawns every 1.2–3.2s
   • Speed increases by 12 px/s per obstacle
   • Player must jump over each one

4. Player hits obstacle → on_hit_obstacle() → died signal
   • _on_player_died() → State = GAME_OVER
   • Spawner stops, "GAME OVER" shown
   • 2.5s countdown begins

5. 2.5s expires → _enter_idle()
   • clear_obstacles() frees any remaining obstacles
   • Player revived at start position, jump disabled
   • Score reset, "PRESS SPACE TO START" shown

Checklist before pressing Play

  • game_manager.gd attached to root Node2D
  • $Player.died connected to _on_player_died in _ready()
  • $ObstacleSpawner.obstacle_scene export set to obstacle.tscn
  • ObstacleSpawner.ground_y = 680.0
  • UI/ScoreLabel and UI/MsgLabel exist in a CanvasLayer
  • obstacle.tscn root is Area2D with obstacle.gd and VisibleOnScreenNotifier2D
  • Obstacle Sprite2D position.y = -42 (base sits on floor at Y=680)

Press Play — the idle parallax demo runs. Press Space — obstacles start appearing. Jump over them. Hit one — Game Over. Watch the auto-reset fire after 2.5 seconds. That's the full loop.


The complete project — scenes, scripts, and assets — is available on GitHub. If you build something from this series, I'd like to see it.