COSMIC GDSCRIPT

BUILD YOUR OWN SPACE SHOOTER

Learn GDScript from scratch while creating a classic 2D shooter inspired by Gaiares, Thunder Force, and other legendary titles. Perfect for beginners!

Spaceship

YOUR LEARNING PROGRESS

Complete sections to unlock the next chapters!

1

GDScript Basics for Space Games

Why GDScript for Shooters?

GDScript is the perfect language for 2D space shooters because it's:

  • Easy to learn with Python-like syntax
  • Optimized for Godot's 2D engine
  • Great for rapid prototyping of game mechanics
  • Perfect for handling physics and collisions

Setting Up Your Project

Start with these basic settings for an authentic arcade feel:

# Project Settings -> Display -> Window
window/size/width = 640   # Classic arcade width
window/size/height = 720  # Vertical shooter height
window/stretch/mode = "2d" # Pixel perfect scaling

# Physics Settings
physics/2d/default_gravity = 0 # No gravity in space!
physics/2d/default_linear_damp = 0.1 # Small drag for inertia

Your First Spaceship Script

Here's a simple script to get your ship on screen:

extends Area2D  # We use Area2D for collision detection

# Ship properties
var speed = 300
var velocity = Vector2.ZERO

func _process(delta):
    # Get input
    var input = Vector2.ZERO
    input.x = Input.get_action_strength("ui_right") - Input.get_action_strength("ui_left")
    input.y = Input.get_action_strength("ui_down") - Input.get_action_strength("ui_up")
    
    # Normalize diagonal movement
    if input.length() > 0:
        input = input.normalized()
    
    # Update position
    velocity = input * speed
    position += velocity * delta
    
    # Keep ship on screen
    position.x = clamp(position.x, 0, get_viewport_rect().size.x)
    position.y = clamp(position.y, 0, get_viewport_rect().size.y)

This gives you basic 8-directional movement with screen boundaries.

Beginner Tip:

Use Input.get_action_strength() instead of Input.is_action_pressed() for smoother analog input support, which works great with gamepads!

2

Advanced Movement Systems

Inertia & Momentum

For games like Thunder Force with realistic space physics:

extends Area2D

var max_speed = 500
var acceleration = 800
var friction = 0.95
var velocity = Vector2.ZERO

func _process(delta):
    var input = Vector2.ZERO
    input.x = Input.get_action_strength("ui_right") - Input.get_action_strength("ui_left")
    input.y = Input.get_action_strength("ui_down") - Input.get_action_strength("ui_up")
    
    if input.length() > 0:
        velocity += input.normalized() * acceleration * delta
        velocity = velocity.limit_length(max_speed)
    else:
        velocity *= friction
    
    position += velocity * delta
    position = position.clamp(Vector2.ZERO, get_viewport_rect().size)

This gives your ship realistic momentum that gradually slows down when not accelerating.

Screen Wrapping

Classic arcade-style screen wrapping (like in Asteroids):

func _process(delta):
    # ... (previous movement code)
    
    # Screen wrapping
    var screen_size = get_viewport_rect().size
    if position.x < 0:
        position.x = screen_size.x
    elif position.x > screen_size.x:
        position.x = 0
    if position.y < 0:
        position.y = screen_size.y
    elif position.y > screen_size.y:
        position.y = 0

Ship Tilting Animation

Add visual feedback when moving:

func _process(delta):
    # ... (movement code)
    
    # Visual tilt based on movement
    var tilt_amount = 15  # degrees
    var target_rotation = -input.x * deg2rad(tilt_amount)
    $Sprite.rotation = lerp($Sprite.rotation, target_rotation, 10 * delta)

Pro Tip: Movement Styles

Different shooters use different movement systems:

  • Fixed: Always moves at constant speed (Gradius)
  • Inertial: Realistic momentum (Thunder Force)
  • Float: Slow acceleration/deceleration (Gaiares)

Exercise:

Try creating a "speed boost" system where holding a button increases max speed temporarily but makes your hitbox larger.

3

Weapons & Power-Ups

Basic Weapon System

A flexible weapon system that supports multiple shot types:

enum WEAPON_TYPES {NORMAL, SPREAD, LASER}

var current_weapon = WEAPON_TYPES.NORMAL
var can_shoot = true
var fire_rate = 0.2

func _input(event):
    if event.is_action_pressed("shoot") and can_shoot:
        shoot()
        can_shoot = false
        $FireRateTimer.start(fire_rate)

func shoot():
    match current_weapon:
        WEAPON_TYPES.NORMAL:
            create_bullet($GunPosition.position, Vector2(0, -800))
        WEAPON_TYPES.SPREAD:
            for i in range(3):
                var angle = deg2rad(-15 + i * 15)
                create_bullet($GunPosition.position, Vector2(0, -800).rotated(angle))
        WEAPON_TYPES.LASER:
            $LaserBeam.emitting = true
            $LaserTimer.start(0.5)

func create_bullet(pos, velocity):
    var bullet = preload("res://Bullet.tscn").instance()
    bullet.position = position + pos
    bullet.velocity = velocity
    get_parent().add_child(bullet)

func _on_FireRateTimer_timeout():
    can_shoot = true

func _on_LaserTimer_timeout():
    $LaserBeam.emitting = false

Power-Up System

Collectible items that enhance your weapons:

# In your ship script
var weapon_level = 0
var max_weapon_level = 3

func _on_Area2D_area_entered(area):
    if area.is_in_group("powerups"):
        match area.powerup_type:
            "weapon_up":
                weapon_level = min(weapon_level + 1, max_weapon_level)
                update_weapon()
            "speed_up":
                max_speed += 50
            "shield":
                $Shield.activate()
        area.queue_free()

func update_weapon():
    match weapon_level:
        0: current_weapon = WEAPON_TYPES.NORMAL
        1: 
            current_weapon = WEAPON_TYPES.SPREAD
            fire_rate = 0.15
        2: 
            current_weapon = WEAPON_TYPES.LASER
            fire_rate = 0.3

Super Cannon (Bomb)

Screen-clearing special attack:

var bomb_count = 3

func _input(event):
    if event.is_action_pressed("bomb") and bomb_count > 0:
        activate_bomb()

func activate_bomb():
    bomb_count -= 1
    $BombAnimation.play("explode")
    $BombSound.play()
    
    # Damage all enemies
    for enemy in get_tree().get_nodes_in_group("enemies"):
        enemy.take_damage(999) # Insta-kill
    
    # Clear bullets
    for bullet in get_tree().get_nodes_in_group("enemy_bullets"):
        bullet.queue_free()

Design Tip:

Balance your weapons carefully:

  • Normal: Weak but reliable
  • Spread: Good for crowds but weaker individually
  • Laser: Powerful but slow firing rate
4

Enemy Patterns & AI

Basic Enemy Movement

Simple but effective enemy patterns:

extends Area2D

enum MOVEMENT_PATTERNS {LINEAR, SINUSOIDAL, CIRCULAR, DIVE}

var health = 3
var speed = 150
var pattern = MOVEMENT_PATTERNS.LINEAR
var time = 0

func _process(delta):
    time += delta
    
    match pattern:
        MOVEMENT_PATTERNS.LINEAR:
            position.y += speed * delta
        MOVEMENT_PATTERNS.SINUSOIDAL:
            position.y += speed * 0.5 * delta
            position.x = 200 + sin(time * 2) * 150
        MOVEMENT_PATTERNS.CIRCULAR:
            position = Vector2(300, 100) + Vector2(cos(time), sin(time)) * 150
        MOVEMENT_PATTERNS.DIVE:
            if position.y < 200:
                position.y += speed * delta
            else:
                var player = get_tree().get_nodes_in_group("player")[0]
                var direction = (player.position - position).normalized()
                position += direction * speed * delta

Bullet Patterns

Classic shooter bullet patterns:

func shoot():
    var patterns = [
        "single", 
        "three_way", 
        "circle", 
        "aimed"
    ]
    call(patterns[randi() % patterns.size()])

func single():
    var bullet = create_bullet(Vector2.DOWN * 300)
    add_child(bullet)

func three_way():
    for i in range(3):
        var angle = deg2rad(-15 + i * 15)
        var bullet = create_bullet(Vector2.DOWN.rotated(angle) * 250)
        add_child(bullet)

func circle():
    for i in range(8):
        var angle = deg2rad(i * 45)
        var bullet = create_bullet(Vector2.DOWN.rotated(angle) * 200)
        add_child(bullet)

func aimed():
    var player = get_tree().get_nodes_in_group("player")[0]
    var direction = (player.position - position).normalized()
    var bullet = create_bullet(direction * 350)
    add_child(bullet)

Enemy Waves & Spawning

Dynamic wave spawning system:

# In your GameController script
var current_wave = 0
var enemies_in_wave = 0
var wave_data = [
    {"count": 5, "type": "basic", "pattern": "linear"},
    {"count": 8, "type": "basic", "pattern": "sinusoidal"},
    {"count": 3, "type": "elite", "pattern": "dive"},
    # ... more waves
]

func start_wave(wave_num):
    current_wave = wave_num
    var wave = wave_data[wave_num]
    enemies_in_wave = wave.count
    
    for i in range(wave.count):
        var enemy = preload("res://Enemies/" + wave.type + ".tscn").instance()
        enemy.pattern = wave.pattern
        enemy.position = Vector2(
            rand_range(50, 590), 
            rand_range(-100, -30)
        )
        add_child(enemy)
        yield(get_tree().create_timer(0.5), "timeout")

func _on_Enemy_death():
    enemies_in_wave -= 1
    if enemies_in_wave <= 0:
        yield(get_tree().create_timer(2.0), "timeout")
        start_wave(current_wave + 1)

Boss Battle Framework

Multi-phase boss structure:

extends Node2D

var max_health = 1000
var health = max_health
var phase = 1
var attack_patterns = [
    "spiral_shot",
    "laser_sweep",
    "missile_barrage"
]

func _process(delta):
    # Phase transitions
    if health < max_health * 0.66 and phase == 1:
        phase = 2
        $AnimationPlayer.play("phase_transition")
    elif health < max_health * 0.33 and phase == 2:
        phase = 3
        $AnimationPlayer.play("final_phase")
    
    # Random attacks
    if $AttackCooldown.is_stopped():
        var attack = attack_patterns[randi() % attack_patterns.size()]
        call(attack)
        $AttackCooldown.start(3.0)

func take_damage(amount):
    health -= amount
    $HealthBar.value = health
    if health <= 0:
        die()

func die():
    $DeathAnimation.play("explode")
    yield($DeathAnimation, "animation_finished")
    queue_free()
    emit_signal("boss_defeated")

Pattern Design Tip:

Good shooter patterns follow these principles:

  • Readability: Players should see and understand the pattern
  • Fairness: Always leave escape routes
  • Variety: Mix different pattern types
  • Rhythm: Attacks should have a predictable tempo
5

Story & Presentation

Cutscene System

Simple dialogue and scene transitions:

# CutscenePlayer.gd
var current_scene = 0
var scenes = [
    {"text": "Commander: The Zeta fleet is attacking our colonies!", "duration": 3},
    {"text": "We need you to pilot the new X-Wing prototype.", "duration": 3},
    {"text": "Good luck out there, pilot!", "duration": 2, "action": "start_game"}
]

func play_scene():
    if current_scene >= scenes.size():
        return
    
    var scene = scenes[current_scene]
    $Label.text = scene.text
    $AnimationPlayer.play("show_text")
    yield(get_tree().create_timer(scene.duration), "timeout")
    
    if scene.has("action"):
        call(scene.action)
    
    current_scene += 1
    play_scene()

func start_game():
    get_tree().change_scene("res://Game.tscn")

Mission Briefing Screen

Display level information:

# MissionBriefing.gd
func show_mission(level_data):
    $Title.text = level_data.title
    $Description.text = level_data.description
    $Objectives.text = ""
    for objective in level_data.objectives:
        $Objectives.text += "• " + objective + "\n"
    
    $AnimationPlayer.play("enter")
    yield(get_tree().create_timer(5.0), "timeout")
    $AnimationPlayer.play("exit")
    yield($AnimationPlayer, "animation_finished")
    emit_signal("briefing_complete")

# Example level data
var level1 = {
    "title": "MISSION 1: BREAKTHROUGH",
    "description": "Penetrate the enemy defense line and destroy the carrier.",
    "objectives": [
        "Destroy all radar installations",
        "Defeat the carrier boss",
        "Survive for 5 minutes"
    ]
}

Ending Sequence

Show player results and story conclusion:

# EndingHandler.gd
func show_ending(score, rank, secrets_found):
    $Score.text = "SCORE: %08d" % score
    $Rank.text = "RANK: " + rank
    
    if secrets_found >= 5:
        $EndingText.text = ending_data.true_ending
    else:
        $EndingText.text = ending_data.normal_ending
    
    $AnimationPlayer.play("scroll_text")
    yield($AnimationPlayer, "animation_finished")
    $ContinuePrompt.show()

var ending_data = {
    "normal_ending": "You defeated the Zeta fleet...\nbut their homeworld remains...",
    "true_ending": "With the Zeta homeworld destroyed...\npeace returns to the galaxy..."
}

Modernizing Classic Storytelling

Techniques to enhance classic shooter narratives:

  • Environmental Storytelling: Show damage to ships, fleeing civilian craft
  • Radio Chatter: Random mission updates during gameplay
  • Unlockable Lore: Collect data logs that expand the universe
  • Branching Paths: Different levels based on mission choices

Exercise:

Create a simple "codex" system where players can view collected story fragments. Store which entries have been unlocked in a dictionary and save/load it using Godot's ResourceSaver.

YOUR SPACE ODYSSEY BEGINS!

You now have all the tools to create an amazing 2D space shooter. Remember that great games are made through iteration - start simple and keep adding polish!

Next Steps

  • Add visual effects (particles, shaders)
  • Implement dynamic music
  • Create an achievement system

Resources

  • Godot Documentation
  • GDQuest YouTube Channel
  • Shmup Dev Discord

Inspiration

  • Thunder Force IV
  • Gaiares
  • R-Type

Made with DeepSite LogoDeepSite - 🧬 Remix