LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
GDScriptIntermediate~2.5 hours

Space Shooter with Enemy Waves

Build a vertical space shooter with a moving ship, instanced bullets, timed enemy spawns, collisions, score and game over.

PackedSceneInstancingTimersCollisions

Overview

A vertical space shooter brings together everything a small arcade game needs: objects created and destroyed constantly at runtime, timers that drive the pace, collision detection between many objects, and a game loop with a score, a game-over state and a restart. It is the natural capstone for the GDScript course.

Your ship slides left and right along the bottom of the screen and fires with Space. Enemies spawn at random positions along the top and fly down, getting faster as your score rises. Shoot an enemy for points; let one reach your ship and the game is over. Press Enter to play again. All art is icon.svg with different colours and sizes, so you can focus on the code.

What You'll Build
  • Bullet and Enemy scenes built on Area2D that move themselves and clean up off-screen.
  • A player ship with a shooting cooldown that instances bullets from a PackedScene.
  • A spawner Timer that creates enemies at random positions and speeds up over time.
  • Collision handling with area_entered and groups to tell bullets, enemies and the player apart.
  • Score, high score for the session, a game-over screen and restart with reload_current_scene().

Prerequisites

  • Scenes, instancing and the scene tree — lesson 5.
  • Lifecycle callbacks — lesson 7.
  • Signals — lesson 8.
  • Vectors and arrays — lesson 10.
  • @export variables — lesson 11.
  • The Coin Collector project (recommended) for Area2D and groups.

Project Structure

res://
├── icon.svg
├── main.tscn / main.gd # spawns enemies, keeps score, game over
├── player.tscn / player.gd # moves and shoots
├── bullet.tscn / bullet.gd # flies up, destroys itself off-screen
└── enemy.tscn / enemy.gd # flies down, reports when shot

Responsibility is split by who knows what. A Bullet and an Enemy only move themselves and detect what they hit. The Player only moves and creates bullets. Main creates enemies, listens to their signals for scoring, and owns the game state. Bullets and enemies are added as children of Main, not the player, so they keep flying independently of the ship.

Pasting Code into Godot

The code on this page is indented with spaces. If Godot reports "Mixed use of tabs and spaces for indentation" after you paste, use the script editor's Edit > Indentation > Convert Indent to Tabs.

Step 1: The Bullet Scene

Create bullet.tscn: an Area2D root named Bullet with a Sprite2D (icon.svg, Scale (0.08, 0.2), Modulate yellow), a CollisionShape2D with a small RectangleShape2D, and a VisibleOnScreenNotifier2D. The notifier emits screen_exited when the bullet leaves the screen, which is the cleanest way to delete it.

Bullet (Area2D) ← bullet.gd
├── Sprite2D ← icon.svg, Scale (0.08, 0.2), Modulate: yellow
├── CollisionShape2D ← RectangleShape2D (10 × 26)
└── VisibleOnScreenNotifier2D
bullet.gd
extends Area2D
@export var speed: float = 700.0
func _ready() -> void:
add_to_group("bullets")
$VisibleOnScreenNotifier2D.screen_exited.connect(queue_free)
func _process(delta: float) -> void:
position.y -= speed * delta

Step 2: The Enemy Scene

Create enemy.tscn the same way: Area2D root named Enemy, a red Sprite2D (Scale 0.4, Rotation 180°), a CollisionShape2D and a VisibleOnScreenNotifier2D. The enemy emits destroyed with its point value when a bullet hits it, and reached_player when it touches the ship. It frees both itself and the bullet on a hit.

enemy.gd
extends Area2D
signal destroyed(points: int)
signal reached_player
@export var points: int = 10
var speed: float = 150.0
func _ready() -> void:
add_to_group("enemies")
area_entered.connect(_on_area_entered)
$VisibleOnScreenNotifier2D.screen_exited.connect(queue_free)
func _process(delta: float) -> void:
position.y += speed * delta
func _on_area_entered(area: Area2D) -> void:
if area.is_in_group("bullets"):
area.queue_free()
destroyed.emit(points)
queue_free()
elif area.is_in_group("player"):
reached_player.emit()

Step 3: The Player Ship & Shooting

The ship is also an Area2D (it never needs physics, only overlap detection). preload() loads the bullet scene once when the script loads; instantiate() creates a new bullet each time you fire. A simple cooldown timer stored in a float limits the fire rate, and Input.is_action_pressed (not just_pressed) means holding Space keeps firing.

Player (Area2D) ← player.gd
├── Sprite2D ← icon.svg, Scale 0.5, Modulate: light blue
├── CollisionShape2D ← CircleShape2D (radius 26)
└── Muzzle (Marker2D) ← Position (0, -36): where bullets appear
player.gd
extends Area2D
const BULLET_SCENE: PackedScene = preload("res://bullet.tscn")
@export var speed: float = 450.0
@export var fire_cooldown: float = 0.2
var _cooldown_left: float = 0.0
func _ready() -> void:
add_to_group("player")
func _process(delta: float) -> void:
var direction := Input.get_axis("ui_left", "ui_right")
position.x += direction * speed * delta
position.x = clampf(position.x, 30.0, get_viewport_rect().size.x - 30.0)
_cooldown_left -= delta
if Input.is_action_pressed("ui_accept") and _cooldown_left <= 0.0:
shoot()
func shoot() -> void:
_cooldown_left = fire_cooldown
var bullet := BULLET_SCENE.instantiate()
bullet.global_position = $Muzzle.global_position
get_parent().add_child(bullet) # add to Main so it flies independently

Step 4: The Main Scene: Waves, Score & Game Over

Create main.tscn with a Node2D root named Main. Instance the player near the bottom centre, add a Timer named SpawnTimer (Wait Time 1.2, Autostart on), and a CanvasLayer named HUD with ScoreLabel and MessageLabel labels. Optionally add a ColorRect filled with a dark navy colour as the first child for a space background.

Main (Node2D) ← main.gd
├── Background (ColorRect) ← optional, Full Rect, dark navy
├── Player (player.tscn) ← Position (576, 590)
├── SpawnTimer (Timer) ← Wait Time 1.2, Autostart: on
└── HUD (CanvasLayer)
├── ScoreLabel (Label)
└── MessageLabel (Label) ← centred

Each tick of SpawnTimer creates an enemy at a random x position just above the screen. Difficulty rises with the score: enemies get faster and the spawn interval shrinks, down to a minimum. On game over, the timer stops, every remaining enemy is removed with call_group(), and the player is hidden and disabled.

Complete Code

bullet.gd, enemy.gd and player.gd are shown in full in Steps 1–3. The main script runs the game:

main.gd
extends Node2D
const ENEMY_SCENE: PackedScene = preload("res://enemy.tscn")
static var high_score: int = 0 # survives reload_current_scene() during this session
var score: int = 0
var game_over: bool = false
@onready var player: Area2D = $Player
@onready var spawn_timer: Timer = $SpawnTimer
@onready var score_label: Label = $HUD/ScoreLabel
@onready var message_label: Label = $HUD/MessageLabel
func _ready() -> void:
spawn_timer.timeout.connect(_spawn_enemy)
message_label.text = "Arrows to move, Space to shoot"
update_score()
func _spawn_enemy() -> void:
var enemy := ENEMY_SCENE.instantiate()
var width := get_viewport_rect().size.x
enemy.position = Vector2(randf_range(40.0, width - 40.0), -40.0)
enemy.speed = 150.0 + score * 2.0 # faster as the score climbs
enemy.destroyed.connect(_on_enemy_destroyed)
enemy.reached_player.connect(_on_player_hit)
add_child(enemy)
# Spawn more often as the score rises, but never faster than every 0.4 s.
spawn_timer.wait_time = maxf(0.4, 1.2 - score / 200.0)
if message_label.text != "" and score > 0:
message_label.text = ""
func _on_enemy_destroyed(points: int) -> void:
score += points
update_score()
func _on_player_hit() -> void:
if game_over:
return
game_over = true
spawn_timer.stop()
get_tree().call_group("enemies", "queue_free")
player.hide()
player.set_process(false)
high_score = maxi(high_score, score)
update_score()
message_label.text = "GAME OVER\nScore: %d\nPress Enter to play again" % score
func update_score() -> void:
score_label.text = "Score: %d Best: %d" % [score, maxi(high_score, score)]
func _unhandled_input(event: InputEvent) -> void:
if game_over and event.is_action_pressed("ui_accept"):
get_tree().reload_current_scene()
Why static var?

reload_current_scene() destroys and rebuilds every node, so ordinary variables reset. A static var (Godot 4.1+) belongs to the script class rather than to a node instance, so high_score survives restarts for as long as the game is running.

Testing the Game

  1. Run the project: enemies start appearing at the top roughly every 1.2 seconds.
  2. Hold Space: bullets fire about five times per second, not every frame.
  3. Shoot an enemy: both disappear and the score rises by 10.
  4. Watch the Remote scene tree (Scene dock > Remote while running): bullets and enemies that leave the screen are removed, so the node count stays small.
  5. Let an enemy reach the ship: enemies vanish, the ship hides, and "GAME OVER" appears.
  6. Press Enter: the game restarts with Score 0, and "Best" remembers your previous score.
HUD at game over

Click Run to see what this code prints.

Space Restarts Too?

ui_accept includes both Space and Enter by default, so holding Space when you die will restart the game instantly. To avoid that, create a dedicated "restart" action bound only to Enter in Project Settings > Input Map and use it in _unhandled_input().

Extend This Project

  • Give enemies health so bigger enemies take several hits (reuse the HealthComponent project).
  • Add an explosion effect with CPUParticles2D spawned where an enemy dies.
  • Make some enemies zig-zag using sin(Time.get_ticks_msec() / 300.0) in _process().
  • Add power-ups that drop from destroyed enemies and enable a triple shot for a few seconds.
  • Save the high score permanently with ConfigFile in user://.

Summary

You built a complete arcade game loop: PackedScenes instanced at runtime for bullets and enemies, a Timer that sets the pace and ramps up difficulty, Area2D collisions resolved with groups, signals carrying score information up to Main, cleanup with VisibleOnScreenNotifier2D, and a game-over/restart cycle. These are the building blocks of most 2D action games you will make in Godot.