Space Asteroid Arcade Shooter

In this Godot tutorial for beginners, we will build a 2D arcade shooter in Godot 3.2. We will learn the basics of the Godot editor and the programming language GDScript. Our game will feature player input, physics objects, positional audio, node inheritance, and Godot signals.

This tutorial is meant for beginners to Godot and programming in general. This means some key information will be repeated for emphasis throughout. The video version of this Godot tutorial can be found here.

We will first need to download the asset files for our game. These files include things like the sound effects and the sprites. Unzip the file and save them somewhere you can easily find later, such as on your desktop.

Setting Up Our Project

Open up Godot. On the right-hand side select "New Project". Name it and save it wherever you like. When you are ready select "Create & Edit".

 

 

You will be greeted with Godot's default, the 3D viewport. We are making a 2D game, so select "2D Scene".

 

Right click and rename the Node2D to "Game".  'Ctrl + s' to save and select the Game scene as your main scene.

Now we need to add the assets folder we downloaded above to our project. You can either navigate to where you saved this project on your computer, or in the File System tab, right click on the Game scene and select "Show in File Manager". This will open up the files of our Godot project in File Explorer on Windows (and the equivalent on Mac and Linux). Copy or move the assets folder to where our Godot project is saved. Take "icon.png" out of the assets folder and overwrite the default icon that Godot generated.

When you open the Godot window again, Godot will start importing our assets.

 

Before we continue we should specify our game's resolution. This is something I like to do early on in every project. You want to work with the resolution you intend your players to play your game in. Knowing what resolution you intend to use early on, can guide later game design decisions, particularly when it comes to artwork.

According to the latest Steam Hardware & Software Survey, over 60% of players are playing PC games on a 1080p monitor. If you are making a mobile game, you will want to do research on the most common mobile phone resolutions. This is especially important for certain art styles such as pixel art. Pixel art requires very specific requirements to look correct, and you will want your game to support all the most common resolutions.

To set your game's resolution, open up the Project Settings.

 

Godot will default to the "General" tab. In the left navigation column, scroll down to and select the "Display > Window" section. Here you can define the Width and Height of your game's default resolution in pixels. You can also define a different Test Width and Test Height that will only be used when developing.

Project Settings > Display > Window

Our game is intended to run well on laptops. A popular laptop resolution is 1366 x 768, but older models of Macbooks use 1280 x 720. It is usually better to have your game be up-scaled on a higher resolution screen, than down-scaled to a lower resolution than it was designed for.

For this tutorial, we are going to use a 1280 x 720 resolution (width = 1280, height = 720). I generally prefer to use a resolution that is smaller than the monitor I am working on. When developing and debugging our game, we will be launching and closing the game very often, and returning to the Godot editor to read errors. For this reason, it is easier to work with a game window that does not take up too much screen space.

 

We will also set the stretch and viewport settings. You may have to scroll down to see these options.

 

We will set the stretch mode to "viewport" and the stretch aspect to "keep". This will ensure that when our game is played on a larger monitor, the edges of our game won't be moved and the aspect ratio is kept the same. This will also be the setting you would want to use if you were making a pixel art game in Godot.

Creating an Asteroid

Now that we have our project set up, let's add some objects to our scene. First we'll make an asteroid. In the scene tree, select the Game node. Select the '+' symbol to add a child node (you can also use the 'ctrl + a' keyboard shortcut).

*Note you can either click through the hierarchy to select the node type you want, or you can type the name of the node you want in the search field to auto-filter.

godot select node

Our asteroid will be of type RigidBody2D. You can find this under:

Node > CanvasItem > Node2D > CollisionObject2D > PhysicsBody2D > RigidBody2D

A quick note on the various Collision Objects and Physics Bodies in Godot.

These are the primary nodes you will use for any objects that exist in your game's world.

  1. The most simple is the StaticBody2D. This for physics objects that don't move in your game, such as floors or walls.
  2. RigidBody2D is meant for objects that move under the influence of the physics engine. This makes it perfect for our asteroids. We want our asteroids to float around and bump into each other.
  3. KinematicBody2D is for an object that receives outside input to affect how it moves, but will also interacts with other physics objects. A common use case for this is the player character. The player will press buttons to move their character, but when the character runs into a wall, it will stop.
  4. Area2D are much more lightweight than the other three. They are meant for objects in your game world that don't interact with the physics engine, other than detect collisions. You'll commonly use Area2D's when you just need a simple hitbox in your game.

Once you add the RigidBody2D to the scene tree, you will notice a little warning icon next to it. Hover it, and it will tell us that our RigidBody2D is missing a collision shape. However, before we set a collision shape, we need to set our asteroid's sprite.

First, right-click on the RigidBody2D. Rename the node to "Asteroid". Add a child node to our "Asteroid" node of type Sprite. The Sprite node should be child of our Asteroid node. If you accidentally made the Sprite a child of the Game node, you can click and drag it over the Asteroid node to make it a child of that. Your scene tree should look like this:

 

Select the Sprite node and take a look at the Inspector tab on the right. We need to set the Texture property of the Sprite. Right now it's "[empty]". Select the drop-down arrow and select "Load".

 

Navigate to the assets folder.

assets > images > asteroids

You'll notice that some of the images might be blurry; we will fix that in a moment. Any of the big ones will do for now. I chose "meteorGrey_big1.png".

So why are some images blurry? This has to do with the import settings of the image files. Depending on the art style of your game, it may make sense to blur any images that are stretched too big in your game. However, with art styles such as "pixel art" we want our images to be displayed exactly how they are presented, even on bigger screens.

These asteroid images are very simple, with only two or three colors and basic shapes. I think they look better when presented "as is". So let's change their import settings.

First, go to the Import tab. It's right next the Scene tab. Then in the FileSystem tab, expand the assets folder and expand the folder until you see all the asteroid images listed. You can either select the image you want, or you can select the top image, hold the "shift" key, scroll down and select the bottom image. This selects all of the images in the folder.

In the Import tab, change the import Preset to "2D Pixel". Then click "Reimport". Godot will re-import all the images you selected with the new settings.

 

Go back to the Scene tab. We are now ready to add a collision shape our asteroid. The collision shape defines how our asteroid will bump and collide with other physics objects. The sprite is purely visual, but we needed to add it first to give us a reference for how we should make our collision shape.

Select the Asteroid node. Add a new node to it of type, CollisionShape2D. Again, if you accidentally added the CollisionShape2D to the Sprite, you can click and drag it over the Asteroid node to reassign it's parent node. Your scene tree should now look like this:

 

You will notice that the warning on our Asteroid node went away. But now there is a warning on our new CollisionShape2D node. We must provide a shape!

Select the CollisionShape2D node and take a look at the Inspector tab. The "Shape" property is "[empty]". Click the drop-down arrow and you'll be presented with some basic shapes for us to use.

You can use any shape you want and if you want to be precise, you can define your own Convex or Concave PolygonShape2D. However, for this tutorial, this isn't the only asteroid sprite we will be using. We will be using other sprites later that are shaped slightly differently. For this reason, it makes sense to just choose an approximate collision shape. Our asteroid sprite is fairly circular, so I'm going to choose a New CircleShape2D.

Some additional properties appeared when we selected the CircleShape2D. Let's change the Radius property to 42. It's not a perfect fit, but it is close enough for our purposes.

 

Back in the Scene tree, right click on the Asteroid node and select "Save Branch As Scene". This will allow us to instance multiple asteroids in our game. We could save our asteroid scene in the root "res://" directory, but let's keep our project organized and create some folders. There is standard way to organize Godot projects, so sometimes you have to decide what makes sense to you. Before we save, select "Create Folder". Name the new folder "objects". Save the Asteroid scene inside the "objects" folder.

 

Make sure you have you Game scene selected in the FileSystem. In the Scene tree, select our newly instanced Asteroid node. In the main 2D editor, move the asteroid so that it is the middle of the the main axes and blue lines which define the bounds of our game's window. You may have to scroll to zoom in or out to see everything. It should look something like this when you're done:

 

Now that we have something in our game, let's try running our project. You can either click the play button at the top or press the F5 key on the keyboard. Godot will compile our code and check for any compile-time errors. After the project compiles successfully, the game window will automatically appear. You should see our asteroid start in the center where we placed it and slowly fall down.

Creating the Player

Now we are ready to create our player. This process will be largely similar to creating the asteroid. In fact, creating an object in any game you make in Godot will follow the same basic format.

Open up the Game scene. You can double-click it in the FileSystem tab.

Before we move on, let's talk a little about what type of node we should use for our player character. For this tutorial, it would make sense to use an Area2D for our player character since we only need to detect when an asteroid collides with our ship, and our ship won't be interacting with the physics engine. When an asteroid collides with our ship, the ship will explode. But instead, we are going to make the player character a KinematicBody2D. My hope is that doing it this way will make it easier to for you to expand upon this tutorial and add your own features and polish to the finished game. By making the player ship a KinematicBody2D, asteroids will bounce off the ship since they are both physics objects.

In the scene tree select the Game node. Add a child node of type "KinematicBody2D". Rename this node to "Player". Your Game scene tree should look like this. Notice the different icons, indicating what type each node is:

 

As a child of the Player node, add a Sprite node. In the Inspector window, click the drop-down arrow next to the Texture property of the Sprite. Select "Load". Navigate to the ship images in our assets folder.

assets > images > ships

You can select any ship you want. For this tutorial, I'm going to select "playerShip3_green.png".

In the Scene tree, select the Player node again. Add child node of type CollisionShape2D. In the Inspector window, on the Shape property click the drop-down arrow.

None of the available shapes really fit the sprite "playerShip3_green.png". So we are going to have to define our own shape. Feel free to use whatever collision shape matches the sprite you chose for the player's ship.

To create our own shape, in the drop-down menu select "New ConvexPolygonShape2D". In the main 2D window, scroll to zoom in on the ship to better see what is going on. Click on the new ConvexPolygonShape2D in the Shape property in the Inspector window to expand its properties. Click on "PoolVector2Array" to expand that.

 

The default ConvexShape2D is of size 3. Or in other words it has 3 points,  which is essentially a triangle. Each index in the PoolVector2Array defines the position of each point in the collision shape. For this sprite we will set the points as follows:

 

As you can see, the collision shape is smaller than the ship sprite and doesn't quite line up. This is an intentional game design decision. Part of game design is determining how your game feels to the player. By making the collision shape of the ship smaller than the sprite, I hope that the player experiences situations where they felt like maybe they should have crashed, but got lucky and barely just made it by the skin of their teeth.

Let's save the Player as its own scene. In the Scene tree, right-click on the Player node and select "Save Branch as Scene". Create a new folder called "characters". We will save the Player scene in there.

 

Open up the Player scene. To do that, you can either click the "movie-clapper" icon next to the Player node in the Scene tree, or double-click the Player scene in the FileSystem window. You will know that you are in the Player scene because the Player node will be the root node in the Scene tree.

 

Player Movement (Our First Script)

Now we are ready to make our first script. In the Player Scene tree, you can either click the "Attach a new script" icon, or right-click the Player node and select "Attach Script".

 

For our script we will use the GDScript programming language. GDScript is a language specifically made for Godot. It has a syntax similar to Python in that it primarily uses spacing to define scope. Don't worry if you are not familiar with all of those terms. Learning programming and game development is a lifelong process. Godot supports other programming languages including C# and C++. While they can be more performant, they are not "first-class citizens" in Godot. Documentation, tutorials, and resources on how to use them just isn't as numerous. Unless you have a strong reason to use another language for your game, GDScript is the best programming language to learn and use with Godot.

Godot automatically inherits the node you are attaching the new script to, so we don't have to worry about setting that. For the Template, select the "Empty" template. The "Default" template generates some code for you that may help you remember the syntax of GDScript, but for this tutorial we will using the "Empty" template and writing our code from scratch. Godot automatically defaults the Path to wherever our Scene is saved.

When you are ready, select "Create".

 

Whenever we open or create a new script, Godot will automatically switch to the Script tab. You can always switch back to the 2D view by selecting 2D at the top.

 

Welcome to the Script View. Here is where most of our game development will happen. This can be daunting for someone new to coding, but with practice, programming will become second nature.

The upper left section of this view, is a list of all your recently opened script files. Ours only lists "Player.gd". The lower left section contains all the functions we have defined in the current file. This allows you to quickly navigate longer files. It is currently empty since we have no functions defined in "Player.gd". The largest section is where we type our code.

Each line in the script editor is numbered. Whenever we get an error, Godot will tell us which file our error happened and on which line number. At the bottom right of the code editor, we see where our cursor currently is. (3, 1) means our cursor is currently on line 3 at the first character on that line.

Notice the links at the top. You can quickly access the official Godot documentation in your web browser using the "Online Docs" link. You can also bring up documentation for a specific node right in the Godot editor with the "Search Help" link. Even the most seasoned of programmers google basic functions and methods, so don't be shy about googling how to use a function and reading the documentation.

"Never memorize something you can look up"

-Albert Einstein

 

Back to our Player.

The physics process function handles all the code that will execute during the physics step. The physics step is designed to run at regular intervals. By default Godot calculates all physics interactions, 60 times every second. This is different from the regular "process" function which runs on every frame, which means it is reliant on the user's frame-rate.

Note: I made it easy to copy and paste the code from this tutorial. But I strongly urge you to type it out yourself. It is important to get familiar with writing code, so you are more prepared to do it on your own.

To help you code faster, Godot will suggest methods and functions to you as you type. You can press the "Tab" key on your keyboard to auto-complete the top suggestion if you wish.

Because our Player's root node is physics object of type KinematicBody2D, we will want to handle movement inside the "physics process" function. Every physics step, we will check if the user is giving the game any input. By default, Godot maps the arrow keys on the keyboard to "ui_left", "ui_right", etc.

extends KinematicBody2D

const SPEED := 600

func _physics_process(delta: float) -> void:
	var velocity := Vector2()

	if (Input.is_action_pressed("ui_left")):
		velocity.x = -SPEED
	if (Input.is_action_pressed("ui_right")):
		velocity.x = SPEED
	if (Input.is_action_pressed("ui_up")):
		velocity.y = -SPEED
	if (Input.is_action_pressed("ui_down")):
		velocity.y = SPEED

	move_and_collide(velocity * delta)

We define a constant with the name "SPEED". A constant is like a variable, except it will not and cannot change value. It is good practice to define any numbers in your code as constants in the top of the file. That way anyone reading your code, including yourself, will know what those numbers mean.

When the user presses any of the arrow keys on the keyboard, we set our variable "velocity" accordingly. "velocity" is a variable of type "Vector2". A Vector2 is just simply a pair of numbers, stored in the properties x and y. When the user presses the left or right arrow key, we set the x value of velocity so that player will move along the X axis. When the user presses the up or down arrow key, we set the y value of velocity so that player will move along the Y axis.

We then call the "move_and_collide" function. We pass our variable "velocity" multiplied by "delta" into the function. This will move the player in the direction and speed we want.

What is delta?

The variable "delta" is automatically set by the Godot engine and is passed into the physics process. Delta holds how much time has passed since the last time the physics process ran. If the game is ever running with a variable frame rate on the user's computer, where the fps is high one second and very low the next, using the delta minimizes how choppy the game looks and feels to the player.

Try running the project now. You can either press the play button in the upper right, or press the F5 key.

We can move!

Screen Wrap Around

Unfortunately, there is a glaring flaw with our game so far. Our player ship can move off the screen and the player will have no way of knowing how to the ship back into view.

Since we are unable to move the camera in our game, we are going to implement screen wraparound to our game objects. Basically when an objects leaves the viewport of our game, it will appear on the other side of the viewport, maintaining it's same velocity.

I did not write this code. A developer by the name of sp33dy made this and published it to his github. You can find a demo project there. This code is outside the scope of this tutorial, but feel free to read it yourself. It is very well commented.

I did, however, add the "recalculate_wrap_area" function to support the user changing the game's window size.

extends Node

# Expose a Rectangle area to the tool, so that it can be defined
export (Rect2) var wrapArea = null

# Expose two flags to determine which axes shall be wrapped; both defaulted true
export (bool) var horizontalWrapping = true
export (bool) var verticalWrapping = true

# Dictionary of axis directions
var AXIS = {
	HORIZONTAL = "x",
	VERTICAL = "y"
}

# Initialise the wrap area to screen size if not set
func initWrapArea():
	if wrapArea == null:
		wrapArea = Rect2(Vector2(), get_viewport().size)

# Recalculate the wrap area again. Call whenever the window size is changed.
func recalculate_wrap_area():
	wrapArea = Rect2(Vector2(), get_viewport().size)

# When node ready, set the inital wrap area if not set
func _ready():
	initWrapArea()
	add_to_group("wrap_around")

# Check whether the parent object is NOT in the wrap area,
# call the wrap function if it isn't
func _process(delta):
	if !wrapArea.has_point(get_parent().global_position):
		wrap()

# The parent Node is NOT in wrap area, so it must be wrapped
# around until it is
func wrap():
	# If horizontal wrapping is enabled
	if horizontalWrapping:
		# Wrap by the horizontal axis
		wrapBy(AXIS.HORIZONTAL)
	# If vertical wrapping is enabled
	if verticalWrapping:
		# Wrap by the vertical axis
		wrapBy(AXIS.VERTICAL)

# Function to determine which side of the axis the parent has fallen off
# i.e. the left or right (x axis) or up or down (y axis)
# Return an integer for the direction the wrap is requred in
# the direction is multiplied by the gap (i.e. width or height
# ..and is added to the current axis position
#
# For example:
#   say the screen is 1024 wide
#     zero indexed, so pixels 0 to 1023
#
#   say the sprite has gone off right (at 1024 pixel)
#   We want to subtract 1024 from 1024
#     to position sprite to the left border at 0
#
# This also work in the opposite direction, for example:
#   say the sprite has gone off left (at pixel -1)
#   We want to add 1024 to the -1
#     to position the sprite to the right border at 1023
#
func getAxisWrapDirection(axis):
	if get_parent().global_position[axis] < wrapArea.position[axis]:
		# off left/top therefore we want to add width or height
		return 1
	elif get_parent().global_position[axis] > wrapArea.size[axis]:
		# off left/top therefore we want to subtract width or height
		return -1
	return 0

# Perform the wrap on the parent object
func wrapBy(axis):
	# Calculate the axis adjustment required
	# I.e. get axis wrap direction and multiply by axis size
	var adjust = getAxisWrapDirection(axis) * wrapArea.size[axis]
	# Apply the adjustment to the parent's position
	get_parent().position[axis] += adjust

To get this working in our game, open the Player scene. Add a new node of type "Node". You may have to scroll up to see it.

 

Rename the node to "Wraparound".

 

Save the "Wraparound" node as it's own scene. Save it into a new folder called "common". We will use this folder to save any custom nodes that may be used by multiple scenes in our game.

 

Open up the Wraparound scene and attach a script to the Wraparound node. Paste the code from above into there. Make sure you don't accidentally have two "extend Node" lines at the top of the file.

When we run the project now, the player ship will wrap around to other side when you move it off the screen.

Let's add screen wrap around to our asteroid as well. Open the Asteroid scene and select the root "Asteroid" node. Instead of adding a built-in Godot node as a child, we will instance one of the scenes we created in this project as a child. To do this, click the "chain link" icon at the top of the Scene tree window.

Select the Wraparound node.

 

Try running the project. Now both the player and the asteroid never leave the game area!

 

Optional Wrap Around Improvment

This next section is completely optional. Our screen wrap around works as is. This next section goes over how to improve the wrap around code to support multiple viewport sizes.

Because we set our game's stretch aspect to "Keep" in the project settings, the number of pixels in our viewport will never change. But this isn't the case for every game.

If you change the window size in a game with a different stretch setting, the wrap around will still be working off the original window size and will not update properly. We have to tell all the Wraparound nodes in our project when the window size changes. We are going to do that using groups and signals. If you open up the Wraparound script, you will notice that in the "_ready" function, we add every Wraparound node to a group called "wrap_around". We will then signal every member of that group whenever the game's window is resized.

Godot signals are a powerful tool. When a special event happens in one of our nodes, we can signal other nodes in our game that the event happened.

 

This allows us to keep our nodes decoupled or separate from each other, but still allow them to communicate. Without signals we would have to rely on connecting through other nodes through the scene tree structure, which is ever changing in even the simplest of games. Beginners to Godot will often use "get_tree" or "get_node" when they should have used a signal. Doing things this way can lead to rigid code structure, that diminishes how modular Godot scenes and nodes were designed to be. As your game gets larger and more complex, this will become unmaintainable.

 

Signals are the way.

In Godot, there is actually a hidden root viewport node. That node emits a signal called "size_changed", whenever the game's window has been resized. Since we do not have direct access to this node, we will use our root Game node to interface with that root viewport node. Open the up Game scene and add a script to the Game node.

 

extends Node2D

func _ready() -> void:
	connect("resized", self, "call_wrap_around")

func call_wrap_around():
	get_tree().call_group("wrap_around", "recalculate_wrap_area")

First we will subscribe our Game node to the "resized" event signal that the viewport gives out. Whenever that signal rings out, our Game node will call it's own function "call_wrap_around" (which we will also define). This function will call every node in the group "wrap_around", and execute their "recalculate_wrap_area" function. With these additions, now any scene in your game with Wraparound should work properly even when the window is resized.

Shooting Lasers

We have our ship. We have an asteroid. Let's implement some shooting mechanics.

We will create a laser weapon for our ship. The art in our game is simple, so the laser weapon won't even have a sprite associated with it. It will only have a coordinate location that defines where the laser beam should spawn from. This makes Node2D the perfect node type to use.

Open up the Player scene. Add a new node of type Node2D. Rename it to LaserWeapon.

 

Right-click LaserWeapon and select "Save Branch as Scene". We will save it in the "objects" folder.

Open up the LaserWeapon scene. Let's attach a script to it.

 

For now we will quickly define a shoot function in our script. We will put the keyword "pass" in the function body, so that we don't get a syntax error. Before we write the LaserWeapon code, we have to figure out what our LaserWeapon will shoot.

func shoot():
    pass

Obviously our LaserWeapon will shoot a laser. So let's define that laser object.

First let's think about the laser in our game. Which root node would be appropriate for it? The laser itself will travel in a straight line. It will collide with other objects in our game, like the asteroids, but it won't interact with the physics engine. For this reason, we will use an Area2D for our laser.

Make sure you have your LaserWeapon scene open. Add a new node of type Area2D.

 

Rename it to Laser.

Let's add a Sprite node to our Laser. In the Inspector window, click the dropdown arrow on the "Texture" property. Select "Load". Navigate to our laser images.

assets/images/lasers/

As you can see, there's a ton of options for us. Again these sprites were made by Kenney. A ton of free video game assets on his website, kenney.nl.

You can choose whichever sprite you want for our laser. For this tutorial, I will be using "laserGreen11.png".

 

Next we will add a CollisionShape2D to our Laser node. In the Inspector window, under the "Shape" property, click the dropdown arrow and select "New RectangleShape2D".

Now instead of clicking the dropdown arrow, click on the text "RectangleShape2D" to open up its properties.

Set the "Extents" so that your collision shape roughly matches the sprite you used. In this case I will make the collision shape a little bigger than the sprite, to make it a little easier to land your shots during gameplay.

Let's save the Laser as it's own scene.

 

We will also save it in the "objects" folder.

Make sure you have the LaserWeapon scene open. We are going to delete the Laser node. "Right-click on the Laser node and select "Delete Node(s)". This won't delete the Laser scene from the file system. Open up the "objects" folder in the FileSystem window and you can see that the Laser scene is still there. This will merely delete the instance of the Laser scene that we had in our LaserWeapon scene.

We won't be creating instances of our Laser scene in the Godot editor. We will use code to do that instead.

Go ahead and open up the LaserWeapon script again. You can either double-click "LaserWeapon.gd" in the FileSystem window, or you can click the script icon next to the root LaserWeapon node.

extends Node2D

var laser_scene := load("res://objects/Laser.tscn")

func shoot():
	var laser = laser_scene.instance()
	laser.global_position = self.global_position
	get_node("/root/Game").add_child(laser)

To instance a scene with code, we first have to load the scene from the file system. To do that we use the "load" function and pass the file path as the argument. Every Godot project has a root folder called "res://".

We can then instance the scene and store it in a variable. We want the laser to spawn from the weapon, so we set the laser's global position to be equal to the LaserWeapon's global position.

Our new Laser instance won't exist in our game's world until we add it to the scene tree. We can use the "add_child" function to do that. Here is something to think about though. If we just call "add_child" right here, the Laser instance will be added as a child of the LaserWeapon. And the LaserWeapon instance in our game is a child of the Player scene.

We actually don't want the lasers to tied to the player once they have been created. What if during gameplay, the player was destroyed? If all the lasers were children of the player node, then they too would be destroyed. We want them to exist and behave independently of the player once they are spawned in the world. Therefore we will add them as a child of our Game node.

 

Input Actions and Events

Now let's write our input code to fire a laser. You can handle user input anywhere in your scene tree. But it's a good idea to think about the structure of your game and what would make sense. We could handle the user input in the LaserWeapon node. But think of the scenario where the player ship has multiple weapons on it. It might make more sense to handle the input on our Player scene. So that is what we will do.

Open up the Player scene.

 

And then open up the Player script, "Player.gd".

When writing input code, it's helpful to understand how the Godot engine handles input.

So how does the Godot engine handle input?

Well first, the user gives input to their machine, whether that be a button press, mouse movement, screen touch, or joystick movement. The machine's OS (operating system) propogates that input to our Godot game. Starting at the root Viewport, Godot will pass the input event to first the "_input" function, then to any Control node "_input", and then finally if the input event wasn't consumed by an GUI elements, it will be passed to the "_unhandled_input" function.

Here is the official documentation on the subject.

If you look in our file "Player.gd" you will notice that used none of these options. We actually referenced an "Input" class from within the "_physics_process" function.

So what's the difference? Which one is right for us to handle shooting lasers?

The long and short of it is that using the "Input" class in the "_physics_process" or "_process" function actually queries the OS every physics step or frame respectively. Whereas using the "_input" and "_unhandled_input" functions let's the OS tell Godot that user input was given. From a performance standpoint, it behooves us to minimizes the calls we make to the OS. Ideally we would want to us the "_input" and "_unhandled_input" functions over the "Input" class in the processing functions whenever it makes sense to.

Since shooting a laser has nothing to do with the user interacting with a GUI, we will use the "unhandled_input" function and add it to our "Player.gd" script.

func _unhandled_key_input(event: InputEventKey) -> void:
	if (event.is_action_pressed("shoot")):
		$LaserWeapon.shoot()

Whenever the user gives us the "shoot" action as input, we will call the "shoot" method on our LaserWeapon instance. Notice the "$" symbol in "$LaserWeapon". This is a shorthand syntax to reference a child node instead of using the "get_node" function.

We referenced an input action named "shoot". But this hasn't actually been defined in our project yet. So let's do that.

Open up the Project Settings. And select the "Input Map" tab.

 

The input map is where we can define which controller inputs and keypresses map to which actions in our game. For the movement code we wrote earlier, we used to some input actions that Godot had already defined by default such as "ui_left" and "ui_right".

Let's define a new action. We will name the action "shoot".

 

Scroll down to the bottom. Our new "shoot" action will be there. Click the "+" symbol to add an input event. We will bind the "shoot" action to a "Key".

 

When you select an option, Godot will start listening for inputs. You can choose whichever key you want. You can even bind multiple keys to a single action. For this tutorial, I am going to bind the "shoot" action to the spacebar. When you have the correct key registered, click "OK" to confirm.

Let's try running our project. When we press spacebar lasers are spawned, but they don't do anything. They don't move or interact.

For this game, we want the lasers to fly upward. So let's implement that. Open up the Laser scene and attach a script to the root Area2D node.

extends Area2D

var direction := Vector2(0, -1)
var projectile_speed := 1000

func _process(delta: float) -> void:
	self.position += direction * projectile_speed * delta

Because our Laser scene is of type Area2D and does not interact with the Godot physics engine, we will put the movement code in the "_process" function instead of "_physics_process". And we will modify the laser's position directly.

 

A quick note about the 'self' variable. In GDScript, 'self' refers to whichever node the self variable is being used in. It's a way for a node to refer to iself. You will often see the 'self' variable referenced implicitly. In our code here, just writing 'position' would work just fine. But sometimes writing out the self variable explicitly can help make your code easier to read and understand.

The 'position' variable is of type Vector2. We want the laser to travel up the Y axis, so we set the Y value to a negative value with our Vector2 "direction" variable. We will multiply by the "delta" value to maintain smooth movement even with choppy framerates.

Now we run our project, the lasers travel upwards as expected.

Here is something to think about. Every time we shoot our laser, we are instancing a new Laser scene. This comes with a small yet non-zero memory cost. Our game is consuming more and more RAM without ever releasing it again. In our game once the laser leaves the viewport, it no longer affects the gameplay. So we should free it from memory.

You can see this in action if you open up your the task manager in Windows or your OS's equivalent. If you keep shooting lasers, our game will slowly consume more RAM. This is what's known as a memory leak.

To help solve this, Godot has a built-in node for us, the VisibilityNotifier2D node. Add the VisibiltyNotifier2D to our Laser scene. We are going to use a signal that the VisibilityNotifier2D provides.

 

Signals are the preferred way for nodes in Godot to communicate with each other. They preserve Godot's modular nature.

When you use functions like "get_parent" or "get_node" to call a node's methods directly, they make a hard assumption about the node structure of your game. That structure or heirarchy could change throughout the course of development. Any nodes that communicate that way will have their connections to each other break when the game is refactored.

In contrast, when a node emits a signal, it does not care who recieves it. It is the responsibility of the listener node to connect itself to the signal to hear when it is emitted. And there is much less reliance on node heirarchy, at least compared to traversing the node tree and calling functions directly.

To connect signals through the Godot editor, we first select the node that will emit the signal. In this case select our new VisibilityNotifier2D node in our Laser scene. We can see what signals our node can emit in the "Node" tab under the "Signals" heading.

Double-click the "viewport_exited" signal. Connect it to the Laser node.

 

This will create a function for us in the Laser node's script. This is the function that will execute when the signal is emitted.

In our game, when a laser leaves the viewport it will no longer be used, so we want to free up the memory it was taking up. Godot does have a function called "free", which will immediately handle this, but I almost always recommend using the "queue_free" function instead. This will allow Godot to finish processing the current frame, and then free the object.

This is good practice to better avoid "null pointer exception" errors. These are errors where an object in your program has been freed, but later on, your code tries to reference that object again.

With the new signal function added, our Laser.gd file should now look something like this.

extends Area2D

var direction := Vector2(0, -1)
var projectile_speed := 1000

func _process(delta: float) -> void:
	self.position += direction * projectile_speed * delta


func _on_VisibilityNotifier2D_viewport_exited(viewport: Viewport) -> void:
	queue_free()

Now our lasers don't endlessly consume memory!

Destroy Asteroids

We can shoot our lasers. Now let's make them hit our asteroids.

Let's connect a signal to our Laser scene. Open up the Laser scene. Select the Node tab and open the Signals section. Double-click the "body_shape_entered" signal.

 

We will connect it to the Laser node. This will generate a function for us in "Laser.gd", that runs any time a Laser overlaps with a physics body.

func _on_Laser_body_shape_entered(body_id: int, body: Node, body_shape: int, area_shape: int) -> void:
	if (body.is_in_group("asteroids")):
		print("asteroid hit")

So far we only have asteroids in our game. But further on we may add more objects that we want to our lasers to interact with in a different way or not at all. We need a way to distinguish our asteroids from other objects in our game. So we will add our asteroids to a group.

Open up the Asteroid scene. Select the Node tab next to the Inspector window. Select the "Groups" section. We will add our Asteroid scene to a group we will name "asteroids".

Go ahead and run the project. Any time a laser travels over the asteroid, it should print "asteroid hit" in the output console. Now that we know when a laser is hitting an asteroid, we can make the asteroid react properly.

Let's attach a script to our Asteroid scene. We will define a function that will run whenever the asteroid gets hit by a laser. We will call this function "explode".

extends RigidBody2D

var is_exploded := false

func explode():
	if is_exploded:
		return

	is_exploded = true

	get_parent().remove_child(self)
	queue_free()

We define a boolean called "is_exploded". The purpose of this variable to prevent this "explode" function from running twice on the same asteroid.

Let's say there is a scenario where two lasers hit the asteroid at the same time. The asteroid would explode from the first laser and be freed from memory in the next frame by the "queue_free" function. But then the game would try to free the same asteroid again because of the other laser, but it has already been freed. We would get a "null pointer exception error", where the object being referenced no longer exists in memory.

The "queue_free" function will allow Godot to finish it's processing and won't free the object until the next frame. But we can remove the asteroid from our game immediately by removing it from the scene tree.

Now that we have defined the "explode" function in our Asteroid scene, we have to call it somewhere.

Open the Laser scene again. We will replace the print statement.

func _on_Laser_body_shape_entered(body_id: int, body: Node, body_shape: int, area_shape: int) -> void:
	if (body.is_in_group("asteroids")):
		body.call_deferred("explode")
		get_parent().remove_child(self)
		queue_free()

Since we first check if the physics body is the "asteroids" group, we know for certain that it has a method called "explode". We will call it with the "call_deferred" function which calls the method during idle processing time. The laser instance will then remove itself from the game.

When we run the project now, shooting the asteroid will make it disappear.

Core Gameplay

I think we are ready to create our core gameplay. We want the asteroids to fly across the screen, down towards the player. And the player must either dodge or shoot the incoming asteroids.

We will use code to spawn our asteroids at regular intervals. The first we need to do is delete the Asteroid node from our Game scene.

We will create a new node of type "Node". We will name it "AsteroidSpawner" and save it as it's own scene. Attach a script to it.

extends Node

var asteroid_scene = load("res://objects/Asteroid.tscn")

func _ready() -> void:
    _spawn_asteroid()

func _spawn_asteroid():
    var asteroid = asteroid_scene.instance()

  asteroid.position = Vector2(50, -100)

    add_child(asteroid)

The "_ready" function is called when a node is instanced. In there we will call our "_spawn_asteroid" function. Similarly to our Laser scene, will instance an asteroid, set it's position, and add it to the scene tree.

Let's try running our project. Our asteroid spawns on the left side of the screen and slowly picks up speed.

We want lots of asteroids in our game. To make them spawn at regular intervals, we will use a Timer node. Make sure you're in the AsteroidSpawner scene and add a Timer node.

Rename it to "SpawnTimer". In the Inspector window turn on "Autostart".

 

Let's connect the signal. Go to the "Node" tab and double-click the "timeout" signal. Connect it to the "AsteroidSpawner" node. In the new function that is created we will call our "_spawn_asteroid" function.

func _on_SpawnTimer_timeout() -> void:
	_spawn_asteroid()

Run the project. Multiple asteroids are spawned in the same spot until there is too many and they begin overlapping and glitching out.

To simulate our player flying through an asteroid field, we want the asteroids to spawn in random location across the top of the screen. We also don't want them to wrap around when they exit the viewport.

Open up the Asteroid scene and delete the WrapAround node there.

 

Go back to "AsteroidSpawner.gd". Let's refactor our code and set a newly spawned asteroid's position within a function.

func _spawn_asteroid():
	var asteroid = asteroid_scene.instance()

	_set_asteroid_position(asteroid)

	add_child(asteroid)

func _set_asteroid_position(asteroid):
	var rect = get_viewport().size
	asteroid.position = Vector2(rand_range(0, rect.x), -100)

First we get the viewport size and store it in a variable. Godot gives us the function "rand_range" to generate a random number between the two argument values we pass it. In this case we want a random number between zero (the left size of the viewport) and "rect.x" (the right size of the viewport). We will spawn our asteroids at "-100" y value so they spawn at the top, off-screen.

Run the project now. The asteroids gently fall down the screen like rain. We're getting closer. We want them to feel like they're careening through space at a high velocity. So let's create a new function to set their angular and linear velocities randomly as well.

func _set_asteroid_trajectory(asteroid):
    asteroid.angular_velocity = rand_range(-4, 4)
    asteroid.angular_damp = 0
    asteroid.linear_velocity = Vector2(rand_range(-300, 300), 300)
    asteroid.linear_damp = 0

We will set the angular velocity at a random value between -4 and 4 so that they spin naturally. We don't want them all to fly straight down so we set the x value of the linear velocity to a random value. We set the linear and angular damp to zero. The damp simulates air resistance and will slow our asteroids down over time.

Don't forget to call our new "_set_asteroid_trajectory" function from within our "_spawn_asteroid" function.

func _spawn_asteroid():
	var asteroid = asteroid_scene.instance()

	_set_asteroid_position(asteroid)
	_set_asteroid_trajectory(asteroid)

	add_child(asteroid)

Run the project and you will see that our asteroids are looking pretty good.

Just like our lasers, we need to free the asteroids that leave the viewport so that our game doesn't continuously consume our computer's memory.

Open up the Asteroid scene. Add a "VisibilityNotifier2D" node. Connect the "viewport_exited" signal to the "Asteroid" node.

 
In the generated function in "Asteroid.gd" we will call the "queue_free" function.

func _on_VisibilityNotifier2D_viewport_exited(viewport: Viewport) -> void:
    queue_free()

Fixing a Bug

While working on this next part of the tutorial, I actually stumbled upon a bug in our project.

If you remember, in the Asteroid script we check if the "explode" function has already been called so that we don't accidentally run code on a freed object. We need to do a similar thing for our Laser scene.

Open up "Laser.gd". In the "_on_Laser_body_shape_entered" function, I would intermittently get an error on the line "get_parent().remove_child(self)". Something to the effect of, "cannot call the function remove_child on a null instance". We were running code on a freed object.

To fix this we will first check that the object has already not been queued to be freed before proceeding.

func _on_Laser_body_shape_entered(body_id: int, body: Node, body_shape: int, area_shape: int) -> void:
	if (!self.is_queued_for_deletion() && body.is_in_group("asteroids")):
		body.call_deferred("explode")
		get_parent().remove_child(self)
		queue_free()

We first make sure that the laser itself hasn't been queued. The exclamation mark is a NOT operator. It means, "do the opposite". So the if statement would read "if 'self' has not been queued for deletion and 'body' is in the group 'asteroids', then...".

And with that, our bug should be fixed.

Shrink Player and Constrain Movement

Before we move on any further, let's finish up our player scene. Open up the Game scene. Move the Player ship closer to the bottom of the in-game viewport (the thin blue line). You may have to change the cursor to "Move Mode".

 

Open up "Player.gd".

We are going to contrain the player's movement to the x axis. They will only be able to move left and right. Delete the up and down code. The physics process function should now look like this.

func _physics_process(delta: float) -> void:
	var velocity := Vector2()

	if (Input.is_action_pressed("ui_left")):
		velocity.x = -SPEED
	if (Input.is_action_pressed("ui_right")):
		velocity.x = SPEED

We want the player's ship to explode whenever it collides with an asteroid. In order to manually detect collisions and implement our own functionality, we are going to use an Area2D node as a hitbox.

Add a new node of type Area2D. Rename it to "Hitbox".

Add a new node of type CollisionShape2D as a child of Hitbox. Select the first CollisionShape2D we created that is a direct child of the Player node.

Select the dropdown arrow of the Shape property. Choose the bottom option of "Copy".

Reselect the Hitbox's CollisionShape2D.

Select the dropdown arrow of the Shape property and paste.

 

Now both the ship's physics interactions as well as the hitbox use the same exact collision shape. This is not a copy of the same shape, this is the same exact shape as it exists in the computer's memory when the game is launched. If one is altered, the other will be as well.

Now let's detect when an asteroid collides with our player ship. Select the Hitbox node. Select the Node tab and open the Signals section. Select the "body_entered" signal and connect it to the Player node.

 

We will write code almost identical to when a Laser collides with an Asteroid.

func _on_Hitbox_body_entered(body: Node) -> void:
	if (!self.is_queued_for_deletion() && body.is_in_group("asteroids")):
		queue_free()

Run the project and you will see that our Player ship now despawns when an asteroid hits it.

Right now the Player ship feels much too large. Or maybe the lasers and asteroids feel much too small. Whichever your perspective, we are going to solve this by change the size of the Player ship.

Open up the Player scene and select the Player node. In the Inspector window, select the "Transform" category. Were are going to make the player ship a smaller. To do that we adjust the Scale property. I chose a value of "0.4".

 

It's important to adjust the scale of the root Player node in this scene and not the Sprite. Applying the scale to the root Player node affects every child node as well including the sprite and collision shapes.

Asteroid Pieces

Now that we have our core gameplay implemented, let's add some polish.

When an asteroid is shot, we want it to break apart into smaller pieces. That means we need to spawn the smaller asteroid pieces.

Our asteroid pieces are actually just going to be smaller asteroids. But we will make a new scene for them that inherits from the normal Asteroid scene.

In the FileSystem window, right-click on "Asteroid.tscn" and select "Duplicate". Name the new scene "AsteroidSmall.tscn".

Open up the AsteroidSmall scene. Rename the root node to "AsteroidSmall" as well. Now select the Sprite node. For this tutorial, I am going to set the texture to "meteorGrey_small1.png".

Select the CollisionShape2D node. Expand the CircleShape2D properties. Change the radius to 12.

Now open the Asteroid scene. Let's spawn these smaller asteroids in the "explode" function in "Asteroid.gd".

 

At the top of the "Asteroid.gd" file under the "extends RigidBody2D" line, let's load the AsteroidSmall scene into a variable.

var asteroid_small_scene := load("res://objects/AsteroidSmall.tscn")

All variables that are defined outside of a function, must be at the top of the file like this.

Let's create a new function.

func _spawn_asteroid_small():
	var asteroid_small = asteroid_small_scene.instance()
	asteroid_small.position = self.position
	get_parent().add_child(asteroid_small)

We name this function starting with an underscore to represent that this function is only meant to be called by this node. Similar to Python, Godot doesn't have truly private methods. So we use this naming convention to remind the programmer not to call this method outside of this file.

Now let's call the "_spawn_asteroid_small" function from within the "explode" function.

func explode():
	if is_exploded:
		return

	is_exploded = true

	_spawn_asteroid_small()

	get_parent().remove_child(self)
	queue_free()

At this point the "Asteroid.gd" file should look something like this.

 

Next we will open the AsteroidSmall scene. Right-click on the AsteroidSmall node and select "Extend Script".

One of the many advantages of the scene and node structure of Godot, is we can use the inheritance design pattern. One of our scenes can inherit from another scene and extend it's functionality. Or in other words, the functions and variables we define in one scene, can be present in another scene simply by extending that scene's script.

By default, "AstroidSmall.gd" extends "RigidBody2D" because that is the type of the root node. We change that to instead, extend our Asteroid's script.

extends "res://objects/Asteroid.gd"

With this simple change, let's try running the project now. When we shoot a small asteroid, a new one is spawned in it's place.

However, we don't want a new asteroid to be spawned when it is a small asteroid that is being shot. So let's overwrite the "explode" function and change how it behaves in our AsteroidSmall scene.

Open up "Asteroid.gd" and copy the entirety of the "explode" function. Open up "AsteroidSmall.gd" and paste the explode function into there. Delete the "_spawn_asteroid_small()" line. Now a new small asteroid won't be spawned when we shoot a small asteroid; only when we shoot the larger ones.

We have now learned how to inherit functionality from a custom scene in Godot by extending it's script. And we have learned how to overwrite functions to change certain behaviors. Extending scripts is an easy to save yourself from writing the same code twice for two nodes that have almost identical properties and functions.

There's no limit to how deep this inheritance can go. If you were making a zoo game for example, you could make a base "Animal" scene, which is inherited by the "Mammal" scene, which is inherited by the "Large Cat" scene, which is inherited by the "Tiger" scene. Adding new animals would be easy and you can certain that if there is a function in the "Animal" script such as "eat", it would also be present in every other animal you defined since they all extend that base animal script.

Okay, let's get back to our asteroids. When a large asteroid explodes, we will spawn four smaller ones. Open up "Asteroid.gd". We will create a new function to spawn four asteroids.

func explode():
	if is_exploded:
		return

	is_exploded = true

	_spawn_asteroid_smalls(4)

	get_parent().remove_child(self)
	queue_free()

func _spawn_asteroid_smalls(num: int):
	for i in range(num):
		_spawn_asteroid_small()

Similar to how we used random numbers for our asteroid field, we want the small asteroids to fly off in random directions to simulate an explosion.

First at the top of "Asteroid.gd" we will define a new variable.

var rng = RandomNumberGenerator.new()

We will again create another function to handle the random trajectory and call that within our spawn function.

func _spawn_asteroid_small():
	var asteroid_small = asteroid_small_scene.instance()
	asteroid_small.position = self.position
	_randomize_trajectory(asteroid_small)
	get_parent().add_child(asteroid_small)

func _randomize_trajectory(asteroid):
	# random spin
	asteroid.angular_velocity = rand_range(-4, 4)
	asteroid.angular_damp = 0

	# randomly choose -1, 0, or 1
	rng.randomize()
	var lv_x = rng.randi_range(-1, 1)
	var lv_y = rng.randi_range(-1, 1)

	# random direction
	asteroid.linear_velocity = Vector2(lv_x * 400, lv_y * 400)
	asteroid.linear_damp = 0

We again give the small asteroid a random spin no greater than 4 in either direction. We generate a random number to determine which direction the smaller asteroid will fly off in. We only want ints so that the smaller asteroids don't gently float off. They either stay in position (with 0) or careen off-screen (with 1 or -1).

Sound Effects

Let's add some sound effects! We have some sound files in the "assets" folder.

Open up the Laser scene. Let's add a new node of type "AudioStreamPlayer2D".

AudioStreamPlayer is the base audio node. It plays audio normally. You would choose that one for music or soundtracks in your game. AudioStreamPlayer2D and 3D have a position property and the audio plays spacially within the game world. Things on the left side of the screen will sound like they are on left side, for example.

In the Inspector window, select the drop-down arrow of the "Stream" property. We can load our audio files from here.

assets/audio/sfx/LaserShoot.wav

Enable the "Autoplay" option. This will play the sound immediately when the Laser scene is instanced. You can play around with the "Volume Db" slider to make it fit with the other sounds in the game. This is the first sound we are adding, so there is nothing to compare it with. This laser sound is actually a bit louder than the other sounds we will be using so I have set the "Volume Db" property to "-7".

Try running the project now. Every time you fire a laser, the appropriate sound will play. And the sound will emit from the current position of the laser.

Let's add some more sounds. Open up the Asteroid scene. Add an "AudioStreamPlayer2D" node. We are going to add a gentle roar noise to our asteroids. In real life, sound would not be able to travel through the vacuum of space. But for our game, we care more about what is fun and what feels good, rather than realism.

assets/audio/sfx/GentleRoarPinkNoise.wav

For this audio file, I set the "Volume Db" property to "-10" and the "Pitch Scale" property to "0.5". We want it to sound a little more rumbly than the default sound is.

We also want this sound to loop, or play again after it has completed playing. To do that, click on the wave pattern image of the Stream property. This will expand the properties of the audio file. Set the "Loop Mode" to "Forward".

 

Next go the Import tab on the left, next to the Scene window. After that, go the FileSystem window and select the "GentleRoarPinkNoise.wav" file. It's import properties should appear in the Import window. Enable the "Loop" option. Don't forget to click the "Reimport" button.

 

With these options set, Godot should automatically set the "Loop End" property for us, to the end of the file. You may have to run the project before it appears.

Run the project now. Now our asteroids have a subtle rumble sound that makes them feel big and heavy.

Juice

Now it's time add what is known as "juice" to our game. Game juice refers to the small details and polish that make a game feel fun to play. A casual player may not be able to articulate these details, but they can feel them.

There is a talk called "The Art of Screenshake" by Jan Willem Nijman that I highly recommend on the subject. He built a demo live, demonstrating the before and after, of every bit of detail he added to a game to make it feel great to the player.

"Just fill your game with love and tiny details."
- Jan Willem Nijman

Tiled Background

The first thing we will do is add our background. Open up the Game scene. Add a new node of type Sprite. Rename it to "Background". In the Scene window, drag the Background node to the top, so it is right under the Game node. This puts the Background behind all other nodes, in terms of what layer they are on in the game, so that the Background does not sit in front of any other nodes in our game.

In the Inspector window, select the drop-down of the Texture property and select "Load".

assets/images/backgrounds/darkPurple.png

I chose the dark purple image but you can use whichever one you want.

Uncheck the "Centered" property.

 

Let's change the Import properties of the image.

In the FileSystem window, expand the folders to the image you used. Select the Import tab. Set the "Repeat" property to "Enabled" and turn off the "Filter" property. Don't forget to press the "Reimport" button.

 

We are going to enable the "Region" property of our Background sprite. This property will make our sprite expand to the extents that we define. Luckily the images we are using tile well, such that there is no visible seams when they repeat.

For this we will make the size of the background based on the size of our viewport. Just in case you forgot what viewport size you are working with, you can find it under the project settings.

Project > Project Settings > General > Display > Window

In my project, I am working with a resolution of 1280 by 720.

We will set the "Region" property of our Background sprite to be 20 pixels larger than our viewport in both dimensions. And then we will set the "Offset" of the sprite to be -10 on both dimensions. By doing this we will have the background be 10 pixels larger than the viewport in every direction.

 

Why don't we just set the region of the Background sprite to be the same size as our viewport? We are creating this overlap because next we are implenting screen shake. Without the extra pixels, the screen shake would make the edges of our background visible. You can always come back and test it once we have screen shake implemented.

Screen Shake

Okay, let's add screen shake. A youtuber by the name of "Game Endeavor" built an elegant screen shake node. It's modular and very easy customize the effect. If you want to learn how the code works, I recommend watching his video.

To get this implemented in our game, open up the Game scene. Add a new node of type Node and rename it to ScreenShake. Save it as it's own scene in the "common" folder.

 

Open up the new ScreenShake scene. Add a node of type Tween. Rename it to "ShakeTween".

Next we will add two Timer nodes. One will be named "Frequency" and the other "Duration".

 

Don't worry about setting their properties in the Godot editor. The script we will be using sets them for us. Attach a script to the ScreenShake node.

extends Node

const TRANS = Tween.TRANS_SINE
const EASE = Tween.EASE_IN_OUT

var amplitude = 0
var priority = 0

onready var camera = get_parent()

func start(duration = 0.2, frequency = 15, amplitude = 16, priority = 0):
	if (priority >= self.priority):
		self.priority = priority
		self.amplitude = amplitude

		$Duration.wait_time = duration
		$Frequency.wait_time = 1 / float(frequency)
		$Duration.start()
		$Frequency.start()

		_new_shake()

func _new_shake():
	var rand = Vector2()
	rand.x = rand_range(-amplitude, amplitude)
	rand.y = rand_range(-amplitude, amplitude)

	$ShakeTween.interpolate_property(camera, "offset", camera.offset, rand, $Frequency.wait_time, TRANS, EASE)
	$ShakeTween.start()

func _reset():
	$ShakeTween.interpolate_property(camera, "offset", camera.offset, Vector2(), $Frequency.wait_time, TRANS, EASE)
	$ShakeTween.start()

	priority = 0

Now let's connect our two timers to this script. Select the Frequency node. Select the "Node" tab on the Inspector window. Make sure you are on the "Signals" section. Double-click the "timeout()" signal. Connect it to the ScreenShake script.

func _on_Frequency_timeout() -> void:
	_new_shake()

Now let's to the same for the Duration timer. Double-click it's timeout signal and connect it to the ScreenShake node.

func _on_Duration_timeout() -> void:
	_reset()
	$Frequency.stop()

Our ScreenShake node will apply it's effect to the camera it is a direct child of. So let's add a camera.

Open up the Game scene. Make sure you have the Game node selected. Add a new node of type Camera2D. Rename it to "MainCamera". Click and drag the ScreenShake node to make it's parent the MainCamera.

Select the MainCamera node again. Save it as it's own scene in the "entities" folder.

As you can see, the MainCamera is not aligned with the viewport we have been working with so far.

 

In the Inspector window, change the camera's "Anchor Mode" property to "Fixed TopLeft".

Last of all set the "Current" property to "On". This will make this the camera view that Godot displays on it's viewport.

 

Let's attach a script to the MainCamera. We will define some functions to call the ScreenShake with some preset values, depending on what is going on in our game.

The first function we make is for when the player shoots a laser.

Open up the Game scene. Select the Player script on the Player node.

 

We will define a new signal called "laser_shoot". We will emit this signal whenever the player shoots a laser. Be sure to write this code above the functions.

"In general, all constants, varaibles, and signals should be defined at the top of the file, above all the function definitions. This makes your code easier to read."

signal laser_shoot

Make sure you have the Player node selected. Open the Node tab on the Signals section. Our new signal should be there. Double-click on the "laser_shoot()" signal. Connect the signal to the MainCamera script.

This will generate a function for us. Here is where we will call our ScreenShake node.

extends Camera2D



func _on_Player_laser_shoot() -> void:
	$ScreenShake.start(0.1, 15, 4, 0)

These values will give a small screen shake effect. Feel free to expiriment with your own values.

There is one more critical step we need to do before we get this working. Go back to the Player script. We created our "laser_shoot" signal and we connected it to a function. But, we have yet to emit the signal. We want to emit this signal whenever the player shoots a laser.

Reopen the Player script. We will update our key input event function when the shoot action is pressed.

func _unhandled_key_input(event: InputEventKey) -> void:
	if (event.is_action_pressed("shoot")):
		$LaserWeapon.shoot()
		emit_signal("laser_shoot")

Connecting Godot Signals Through Code

Next, we also want to add screen shake for when an asteroid explodes. But, because we instance our asteroids through code, we will have to connect the signals through code as well.

Open up the Asteroid scene and open it's script. Once again we will define a new signal for when the asteroid explodes.

extends RigidBody2D

signal explode

Add it above all the variable definitions.

We will emit this signal in the "explode" function below the "is_exploded" check.

func explode():
	if is_exploded:
		return

	is_exploded = true

	emit_signal("explode")

	_spawn_asteroid_smalls(4)

	get_parent().remove_child(self)
	queue_free()

Now let's connect the signal to our MainCamera. We will do this in the "_ready" function. This is a predefined function in Godot that runs whenever the node is created.

func _ready() -> void:
	var main_camera = get_node("/root/Game/MainCamera")
	self.connect("explode", main_camera, "asteroid_exploded")

Now let's create that "asteroid_exploded" function in our MainCamera. Open up the MainCamera script.

func asteroid_exploded():
	$ScreenShake.start(0.1, 15, 12, 2)

And for that extra level of polish, let's have our smaller asteroids have their own unique level of screen shake. Open up the AsteroidSmall scene and it's script.

func explode():
    if is_exploded:
        return

    is_exploded = true

    emit_signal("explode")

    get_parent().remove_child(self)
    queue_free()

We need to emit the signal here as well, since we overrode the explode function. And let's override the "_ready" function as well, to call a different method in our MainCamera.

func _ready() -> void:
	var main_camera = get_node("/root/Game/MainCamera")
	self.connect("explode", main_camera, "asteroid_small_exploded")

Go back to the MainCamera script. Add the new function.

func asteroid_small_exploded():
    $ScreenShake.start(0.1, 15, 8, 1)

Go ahead and run the project. Our game should have screen shakes galore.

Infinite-Scrolling Background

We are going to continue to add "juice" to our game. We will simulate our ship flying through space by using an infinite-scrolling background.

There are multiple ways we could accomplish this, including with GDScript. But we are going to implement this using shaders.

Godot shaders are written in the Godot shading language. This language is optimized to run on the GPU.

If you feel like you were just starting to learn GDScript and now I'm throwing you a curve with shaders, do not fret. Writing shaders is it's own area of expertise. It's heavily math-based compared to regular programming. There are entire books on the subject. We won't be deep diving into shaders today. We are just introducing the topic so you are aware that it exists.

Open up the Game scene and select the Background node.

We will be adding a new shader material to this node. In the Inspector window, expand the "Material" section. Select the drop-down arrow and select "New ShaderMaterial".

A white sphere will appear, representing the material we have applied. So far there have been no changes.

Things like the color of an object is usually handled by the texture of the sprite. But other properties of texture, such as roughness or shininess are stored in it's material. You will see materials used much more often in 3D games.

Select the material (the white sphere) to expand it's properties. Click the drop-down arrow of the "Shader" property and select "New Shader".

Select the Shader. This will open up a text editor window underneath the 2D viewport. This is where any shader code you write will go. You may have to expand the window to see it better.

shader_type canvas_item;

uniform vec2 direction = vec2(0.0,-1.0);
uniform float speed_scale = 0.5;

void fragment(){

     vec2 move = direction * TIME * speed_scale;

     COLOR = texture(TEXTURE, UV + move);   
}

Our background is moving! Because shader code runs on the GPU, we will see it's effects right in the Godot editor. We don't even have to launch the game.

Go ahead and launch the game anyway. See how much better the gameplay feels with that small change.

Asteroid Explosion with Particles

Now we will talk about one of these easiest ways to add "juice" to our game, and that's with particles. Many games use particles to give impact to their visuals. Like shaders, particles make use of GPU acceleration.

The first effect we will create using particles is a simple explosion for our asteroids. Our asteroids already explode into multiple large pieces, but this added effect will give the visuals of smaller shrapnel being ejected from the asteroid.

Open up the Game scene. Add a new node of type "Particles2D".

"CPUParticles2D is not optimized for the GPU and is meant for older phones that do not support GLES3."

Rename the node to "ParticlesAsteroidExplosion". You can choose a shorter name if you wish. I prefer longer variable names over ambiguity. Save it as it's own scene in the "objects" folder. Now delete the "ParticlesAsteroidExplosion" node from the Game scene.

Open the "ParticlesAsteroidExplosion" scene. You can find it's file in the FileSystem window.

We get a warning icon next to the particle node saying there is no material assigned.

So let's fix that. In the Inspector window, expand the "Process Material" section. Select the drop-down arrow of the "Material" property. Select "New ParticlesMaterial".

Zoom in to the origin point of (0, 0), where the X axis and Y axis intersect in the 2D viewport of the Godot editor. You can use the scroll wheel to zoom. You can click and drag with the scroll wheel (otherwise known as mouse button 3) to pan the viewport. You can also use the zoom and pan buttons available in the Godot interface.

Zoom in until you can see the particles. The default effect is white squares falling from a point, almost like a leaking water faucet.

The great thing about particles is how powerful they are. You can create so many effects with them that would normally be expensive to run on the CPU. And we have access to tons of properties that we can set right in the Godot editor. The amount of properties may seem overwhelming at first. But the only way to learn is to jump in.

Make sure you have the ParticlesAsteroidExplosion node selected so that it's properties appear in the Inspector window. I highly recommend that you experiment with each property one by one and see how it changes the particle effect. Not every property is self-explanatory and the only way you will learn it is by trying it out.

When creating and testing your particle effect, it's important to keep the "Emitting" property on and the "One Shot" property off. Both these settings will stop the particle from emitting. We can change these two settings to their final setting, once we are done testing.

The collapsed sections were not changed from their default values.

You will want to set the "Color" property to match the color of your asteroids.

Once you are happy with the effect, scroll back to the top of the Inspector window and turn on "One Shot".

This setting makes our effect run only once before turning off, which is perfect for this simple explosion.

Spawning Explosion Particles with GDScript

Now we are ready to implement these explosion particles into our game. Open up the Asteroid scene and it's script.

We will define a new function to spawn the explosion particles when the asteroid explodes. The first step is to load in the particle scene at the top of the file with our other variable definitions.

var explosion_particles_scene := load("res://objects/ParticlesAsteroidExplosion.tscn")

And then the function to instance and spawn the particles.

func _explosion_particles():
    var explosion_particles = explosion_particles_scene.instance()
    explosion_particles.position = self.position
    get_parent().add_child(explosion_particles)
    explosion_particles.emitting = true

We create an instance of the explosion particles and set it's position to be the same as the asteroid's. We then add particles to scene tree, but not as a child of the asteroid because the asteroid instance is about to free itself from memory. And finally we turn on the particles by setting "emitting" to true.

And finally don't forget to actually call this new function. We will call it in the "explode" function after the "is_exploded" check.

func explode():
    if is_exploded:
        return

    is_exploded = true

    _explosion_particles()

    emit_signal("explode")

    _spawn_asteroid_smalls(4)

    get_parent().remove_child(self)
    queue_free()

And we have our first particles in our game! Feel free to tweak the effect to your liking now that you can see how it looks in-game.

Asteroid Explosion Sounds

We have some nice visuals for when an asteroid expodes. Now let's add the audio.

Open the Asteroid scene and it's script.

func _play_explosion_sound():
    var explosion_sound = AudioStreamPlayer2D.new()
    explosion_sound.stream = load("res://assets/audio/sfx/AsteroidExplosion.wav")
    explosion_sound.pitch_scale = 1
    explosion_sound.position = self.position
    get_parent().add_child(explosion_sound)
    explosion_sound.play(0)

Just like with the particles, we need instance a new AudioStreamPlayer2D, set it's position to be the same as the asteroid, and then add it as a child of the asteroid's parent, so that it doesn't get freed before it has had a chance to play.

And we will call it in the explode function.

func explode():
    if is_exploded:
        return

    is_exploded = true

    _explosion_particles()
    _play_explosion_sound()

    emit_signal("explode")

    _spawn_asteroid_smalls(4)

    get_parent().remove_child(self)
    queue_free()

Remember that we overwrote the explode function in our small asteroids. So let's update it's explode function as well.

Open the AsteroidSmall script.

func explode():
    if is_exploded:
        return

    is_exploded = true

    _explosion_particles()
    _play_explosion_sound()

    emit_signal("explode")

    get_parent().remove_child(self)
    queue_free()

func _play_explosion_sound():
    var explosion_sound = AudioStreamPlayer2D.new()
    explosion_sound.stream = load("res://assets/audio/sfx/AsteroidExplosion.wav")
    explosion_sound.pitch_scale = 1.2
    explosion_sound.position = self.position
    get_parent().add_child(explosion_sound)
    explosion_sound.play(0)

We will also change the audio of the smaller asteroid explosion to be a bit higher pitched.

Run the project. Once you improve the visuals and add sound, the game really comes to life!

Keeping Score

Shooting asteroids is fun but, let's give the player something measureable to track their performance. Let's give each a asteroid a point value and add those points to the player's score when they shoot it.

The first we will do is display the players score in the upper right corner of the screen. We are now entering the world of Graphical User Interfaces or GUIs. Before computer mice or trackballs existed, you typed in commands in a lime green font on a black background. Computers ran entirely on the command line. Instead of double-clicking on icons, you typed in the name of program you wanted to run. GUIs changed all that.

Open up the Game scene. Make sure you have the Game node selected. Let's add a new node of type Control. Rename it to GUI. Add a new node of type MarginContainer as it's child.

 

In the Inspector window, expand the "Margin" property. We will set "Right" to the width of our viewport. And we will set "Bottom" to 60 pixels. This makes it so that all of our GUI elements are contained at the top of the screen.

Expand the "Custom Constant" section. We will set the "Margin Right" and "Margin Left" to 20. Set "Margin Top" and "Margin Bottom" to 10. This will make any GUI elements that are children of the MarginContainer stay that many pixels away from the edge of the container.

As a child of the MarginContainer, add a new node of type HBoxContainer. With that added you can see the margins in action.

As a child of the HBoxContainer, add a new node of type VBoxContainer. Expand the "Size Flags" section in the Inspector window and check the "Expand" property under "Horizontal".

Finally as a child of that, add a new node of type Label. Rename it to "Score".

Change the "Align" property to "Right". In the "Text" field, write "0".

Let's change the font. Expand the "Custom Fonts" section. Click the drop-down arrow and select "New DynamicFont".

Click on the DynamicFont to expand it's properties.

Expand the "Settings" section. Change the "Size" property to 32. Expand the "Font" property. Click the drop-down arrow. Select "Load".

/assets/font/UbuntuMono-Bold.ttf

Feel free to use any font you want. You can find the font files that came with your computer, or download a font off the web.

Now that we have a string in the upper right corner of the screen, we will use to code to change it's value.

Attach a script to the Score node. Let's change the path where it saves. Select the folder icon next the Path.

This will allow us to choose a custom location. Create a new folder called "ui". Save the script in there.

Let's write a new function to update the score.

func update_score(points_scored: int):
    var score = int(text)
    score += points_scored
    text = str(score)

First we will grab the string in the "text" property of the label and cast it as an int. We then mathematically add the given "points_scored" to the score. We then need to cast the result back to a string and save it in the "text" property.

We want the score to update whenever we destroy an asteroid. The first thing we need to do for that is to give each asteroid a value, how many points you get for destroying it.

Open up the Asteroid scene and it's script. At the top of the file, let's declare a new variable.

var score_value = 100

Open up the AsteroidSmall script. In the "_ready" function, change the value to 250.

func _ready() -> void:
    var main_camera = get_node("/root/Game/MainCamera")
    self.connect("explode", main_camera, "asteroid_small_exploded")
    score_value = 250

We are giving the smaller asteroids more points since they are much more difficult to hit. And it's often safer to dodge them rather than shoot them.

Now let's create a signal for the score.

Open up the Asteroid script again.

signal score_changed

In the Asteroid script, let's connect this signal to our label in the "_ready" function.

func _ready() -> void:
    var main_camera = get_node("/root/Game/MainCamera")
    self.connect("explode", main_camera, "asteroid_exploded")
    var label = get_tree().get_root().get_node("Game/GUI/MarginContainer/HBoxContainer/VBoxContainer/Score")
    self.connect("score_changed", label, "update_score")

Let's emit this signal in the "explode" function. We will pass the "score_value" along with it.

func explode():
    if is_exploded:
        return

    is_exploded = true

    _explosion_particles()
    _play_explosion_sound()

    emit_signal("explode")

    emit_signal("score_changed", score_value)

    _spawn_asteroid_smalls(4)

    get_parent().remove_child(self)
    queue_free()

Make sure to update the AsteroidSmall script and emit the signal in the "explode" function as well.

Try running the project. The score now updates every time we shoot an asteroid.

Score Juice

We can go a step further. When an asteroid explodes, we will display how many points that asteroid was worth atop of the explosion.

Open up the Asteroid scene. Select the Asteroid node. Add a new node of type Node2D. Rename it to "PointsScored". Save it as it's own scene in the "ui" folder.

Delete the PointsScored from the Asteroid scene. We will be spawning the PointsScored scene with code.

Open up the PointsScored scene. Add a new node of type "Label". Select the PointsScored node again. Add a new node of type "Timer". Attach a script to the PointsScored node.

Let's set up the Timer.

Select the Timer node. In the Inspector window enable the "One Shot" property and the "Autostart" property. Select the Signals section on the Node tab. Double-click the timeout() signal.

Connect it to the PointsScored node.

func _on_Timer_timeout() -> void:
    queue_free()

With our timer set up like this, the PointsScored will free itself after one second.

Select the Label node again. Let's set some of it's properties.

In the Text field type in "100". Let's center the Label with the origin in the viewport.

Set the "Align" and "Valign" property to "Center".

Expand the Margin property. Set it's values so that the number is centred on the X and Y axis.

Let's add the same custom font. Expand the "Custom Font" and select "New Dynamic Font". Click the drop-down of the "Font Data" and load the same font you used before.

assets/fonts/UbuntuMono-Bold.ttf

Let's change the color of the font as well. Expand the "Custom Colors" section. Check the "Font Color" and make it a yellow color. Enable the "Font Color Shadow" as well. The default black color is fine.

Points Scored Audio

Let's add some audio to spice this up even more. Select the PointsScored node. Add a new node of type "AudioStreamPlayer2D".

In the Inspector window, click the drop-down arrow of the Stream property. Load the ScorePoint sound file.

assets/audio/sfx/ScorePoint.wav

You will have to set the Volume of this sound to fit in your soundscape as well. I'm setting mine to "-3". Turn on Autoplay as well.

We are ready to spawn this when an asteroid explodes.

Open up the Asteroid scene and it's script.

Let's load the PointsScored scene at the top of the file.

var points_scored_scene = load("res://ui/PointsScored.tscn")

Let's define a new function underneath the "explode" function.

func _spawn_score():
    var points_scored = points_scored_scene.instance()
    points_scored.get_node("Label").text = str(score_value)
    points_scored.position = self.position

    get_parent().add_child(points_scored)

We instance the points scored scene. We set the label text to be the value of the asteroid's score value. We set the position to where the asteroid is.

Call our new "_spawn_score" function in the "explode" function. Put it next to the "score_changed" signal being emitted line.

emit_signal("score_changed", score_value)
    _spawn_score()

Don't forget to add these same lines the AsteroidSmall script as well.

Run the project. Shooting the asteroids is now very fun!

Exploding the Player

We have been fleshing out all the details and features of the game. But in the meantime, we've never implemented the ending of our game. What happens when our player dies?

Open up the Player scene and it's script. Right now when the player touches an asteroid, it simply frees itself. Let's add an explosion. This will be an almost identical process to the asteroid explosion.

Add a new node of type "Particles2D". Rename it to "ParticlesPlayerExplosion". Right-click the node and select "Save Branch as Scene". Save it in the "objects" folder.

Delete the ParticlesPlayerExplosion node from the Player scene.

Open the ParticlesPlayerExplosion scene. Once again we will set all the properties of the particle to make an explosion.

First let's define the Material.

And then define the material's properties.

Curves Are Cool

Expand the "Linear Accel" section. Set the first two properties. "Accel" to 100 and "Accel Random" to 0.2.

We will also be defining an acceleration curve. This feature in the Godot engine allows use to define the linear acceleration of the particles throughout their lifetime, without having to use complex equations. We can simply move points around the graph, and visually see the numbers.

Click the drop-down arrow and select "New CurveTexture".

Click on the new "Curve" graph that appears. Set the Min and Max Values to -400 and 400 respectively.

Set the curve like so. You can right-click on the line to add points. When you have a point selected, you change it's rotation to affect the curve around it.

Notice how the paricles now move very quickly when they first spawn, and slow down over their lifetime. Hardly anything in nature is ever linear. You can use curves to make your effects feel very natural.

Let's use a curve for the Scale property as well.

This changes the scale of the particles over time.

Instead of choosing a single color for our particles, let's use a gradient.

Expand the "Color" section. Select the drop-down next to "Color Ramp" and select "New GradientTexture".

Now create a new gradient.

Click on the Gradient to expand it's properties.

You can click around on the gradient to edit it. Or you may find it easier to edit the values of the "Offsets" and "Colors" arrays directly.

Once you are satisfied with the explosion visuals, scroll back to the top of Inspector window and enable "OneShot".

Player Explosion Audio

Let's add the audio of the explosion. In the ParticlesPlayerExplosion scene, add a new node of type "AudioStreamPlayer2D".

In the Inspector window, select the drop-down arrow next to the "Stream" property and select "Load".

assets/audio/sfx/ShipExplosion.wav

Enable "AutoPlay".

Spawning the Explosion

Open the Player scene and it's script. At the top, let's load our new explosion scene.

var player_explosion_scene = load("res://objects/ParticlesPlayerExplosion.tscn")

Let's define a new function to handle the explosion code.

func explode():
    var explosion = player_explosion_scene.instance()
    explosion.position = self.position
    get_parent().add_child(explosion)
    explosion.emitting = true

    queue_free()

Don't forget to call this explode function in the signal function call whenever the player gets hit by an asteroid.

func _on_Hitbox_body_entered(body: Node) -> void:
    if (!self.is_queued_for_deletion() && body.is_in_group("asteroids")):
        explode()

Run the project. Our player's ship now explodes!

Music

Now let's add the game's music.

Open up the Game scene. Select the Game node. Add a new node of type "AudioStreamPlayer". Notice we are not using AudioStreamPlayer2D. We don't want the music to play positionally.

Rename the node to "MusicPlayer". Load the music audio file for our game.

assets/audio/music/sawsquarenoise_-_03_-_Towel_Defence_Ingame.ogg

Click on the audio wave graph of the Stream property to expand the audio file's properties. Make sure "Loop" is enabled.

Once again you want to set the volume relative to the other sounds in the game. I'm using -10. Enable "AutoPlay".

Game Over

Now that the player explodes, let's end the game with a Game Over message.

Open up the Game scene. Select the Game node. Add a new node of type Label. Rename it to "GameOverLabel".

In the "Text" field, type in your "game over" message. I will simply type "Game Over".

Set the "Align" and "Valign" properties to "Center".

Expand the "Custom Fonts" section. Create a new DyanamicFont and load the same font we have been using.

assets/fonts/UbuntuMono-Bold.ttf

Set the font size to 200. In the "Custom Colors" section enable "Font Color Shadow".

Click and drag the label to the center of the viewport.

When you are satisfied with how the Game Over message looks, go back to the scene tree window. Change the GameOverLabel to not visible.

We will make this visible with code when the player dies.

Game Over Signal

Open up the Game scene. From there open the Player script.

We do it this way, so that we can easily connect the signal through the Godot interface.

In the Player script, let's define a new signal to emit whenever the player dies.

signal laser_shoot
signal player_died

Then we will emit this signal in the "explode" function.

func explode():
    var explosion = player_explosion_scene.instance()
    explosion.position = self.position
    get_parent().add_child(explosion)
    explosion.emitting = true

    emit_signal("player_died")

    queue_free()

Select the Player node. Go to the Signals window. Double-click on the "player_died()" signal.

Connect it to the root Game node.

This will generate a new function for us. At top this function add a comment.

# Game Over
func _on_Player_player_died() -> void:
    pass # Replace with function body.

It's good practice to add comments throughout your code, especially for things that are not obvious at a glance. If you came back to this project after a few weeks and wanted to edit what happens when it's "Game Over", it may not be obvious that you need to go to the function called "_on_Player_player_died()". Especially if you've added many more features and the script file has many more lines of code to scroll through.

You can end the game however you want. In fact, I challenge you to implement a better Game Over screen then what we will be doing in this tutorial.

Make sure you have the Game scene open. Add a new node of type "Timer". Rename it to "GameOverTimer". Enable the "OneShot" property.

You will see why we need a timer once it's implemented. We will use it to give the player explosion some breathing room before the game over music begins.

With the GameOverTimer node selected, open the Signals tab. Connect the "timeout()" signal to the Game node. This will generate a function for us.

We will use the "_on_Player_player_died" function to handle everything we want done immediately when the player dies. We will use the "_on_GameOverTimer_timeout" function to handle everything we want done one second after the player dies. In between this gap, the player's ship will explode.

# Game Over
func _on_Player_player_died() -> void:
    # stop gameplay music and load "game over" music
    $MusicPlayer.stop()
    $MusicPlayer.stream = load("res://assets/audio/music/sawsquarenoise_-_06_-_Towel_Defence_Jingle_Loose.ogg")
    $MusicPlayer.stream.loop = false
    $MusicPlayer.volume_db = -5

    # stop new asteroids from spawning
    $AsteroidSpawner/SpawnTimer.stop()

    # turn off "roaring" sound for every already-spawned asteroid
    for a in get_tree().get_nodes_in_group("asteroids"):
        a.get_node("AudioStreamPlayer2D").stop()


    $GameOverTimer.start()

func _on_GameOverTimer_timeout() -> void:
    # play "game over" music and show "game over" screen
    $MusicPlayer.play(0)
    $GameOverLabel.visible = true

Run the project. Our game now has a satisfying ending!

Fixing Another Bug

Well, while working on the next part I found another bug. In the for-loop where we are turning off the asteroid sound during a game over, we access a node that is only present in the regular Asteroid scene, but not in the AsteroidSmall scene. Therefore, the game crashes if there any small asteroids in the scene tree.

We need to first check if the "AudioStreamPlayer2D" node exists before we attempt to access it.

# turn off "roaring" sound for every already-spawned asteroid
for a in get_tree().get_nodes_in_group("asteroids"):
    if (!a.is_queued_for_deletion() and a.has_node("AudioStreamPlayer2D")):
        a.get_node("AudioStreamPlayer2D").stop()

 

Difficulty Ramp Up

Our game is a little too easy at the moment. Any high scores you get, are more indicitive of your patience rather than skill. So we will increase the difficulty of the game over time. The way we will do this is, is by spawning asteroids at a higher rate.

Open up the AsteroidSpawner scene and it's script. Let's a new node of type Timer. Rename it to "DifficultyTimer".

Enable the "AutoStart" property. Set the "Wait Time" to 5 seconds. Connect the "timeout" signal to the AsteroidSpawner.

Let's define some variables.

var asteroid_spawn_interval := 2.0
var difficulty_index := 1.5

The difficulty_index is a value used in calculating how often asteroids spawn. Look at the next part of code to see how it is used. Now let's define what happens when the difficulty timer runs out.

func _on_DifficultyTimer_timeout() -> void:
    $SpawnTimer.wait_time = float(asteroid_spawn_interval) / float(difficulty_index)
    difficulty_index += 1
    print($SpawnTimer.wait_time)

When the DifficultyTimer expires, we will change the wait time of the SpawnTimer. This will make asteroids spawn more frequently over time. We cast the values as floats to ensure the result is also a float. This bit may be unnecessary, try it out without the casting and see if you run into any issues. We print the SpawnTimer wait_time for easier debugging; comment it out if you don't need it.

Restart Game

Finally, let's implement an easy to restart the game. As of now, we've had to recompile the project every time. We will implement a function to simply reset all the necessary variables while the game is still running.

First let's define an action for restarting the game. Open up the project settings.

Navigate to the Input Map section and add a new action called "restart_game".

Click the "+" icon and add a keybind for this action.

Bind the "restart_game" action to the space key.

Let's give the player some in-game instructions to let them this binding exists.

Open up the Game scene. Add a child node to GameOverLabel of type Label. Rename it to RestartLabel.

Make the GameOverLabel visible again by clicking "eye" icon, so we can see what we are working with. Set the "Align" and "Valign" properties to "Center".

Just like with the other labels, use the same font, enable "Font Color Shadow", and set the size to 42.

Click and drag the label to where you want it on the screen.

Once you are satisfied with the label, don't forget to make the GameOverLabel invisible again. You can leave the RestartLabel as visible, since it will inherit that property from it's parent node, the GameOverLabel.

Since we are using the spacebar for both shooting and for restarting the game, we will need to implement a check to make sure the game isn't being restarted erroneously. We will define a new boolean for that.

var is_game_over := false

At the bottom of the file, we will listen for that action.

func _unhandled_input(event: InputEvent) -> void:
    if (is_game_over and event.is_action_released("restart_game")):
        _restart_game()
        
func _restart_game():
    pass

First we check if the game is over, and only then will we restart the game. We will say the game is over when the "Game Over" label appears.

func _on_GameOverTimer_timeout() -> void:
    # play "game over" music and show "game over" screen
    $MusicPlayer.play(0)
    $GameOverLabel.visible = true
    is_game_over = true

Now let's write the "_restart_game" function. The "_restart_game" function will comprised of several helper functions. We will do our best to split this into seperate functions, handling seperate tasks.

We will first call a function to undo all the music and label changes that happen when it's game over. We will call this function "_undo_game_over".

func _undo_game_over():
    $GameOverLabel.visible = false
    $MusicPlayer.stop()
    $MusicPlayer.stream = load("res://assets/audio/music/sawsquarenoise_-_03_-_Towel_Defence_Ingame.ogg")
    $MusicPlayer.stream.loop = true
    $MusicPlayer.volume_db = -10
    $MusicPlayer.play(0)

Next we will respawn the player into the game.

Be sure to import the player scene at the top.

var player_scene = load("res://characters/Player.tscn")
func _respawn_player():
    var player = player_scene.instance()
    player.position = Vector2(626, 680)
    add_child(player)

You can click on the Player node in the Game scene to see where it is first positioned.

Here is where we must correct an unfortunate side effect. So we initially instanced the Player through the Godot interface, and connected it's signals through the Godot interface as well. But now in this "_respawn_player" function, we are instancing the player through code. But when we do it this way, the signals are not connected. So we must connect them through code instead.

Open up the Player script. We will do this in the "_ready" function, which runs as the node is being instanced.

func _ready() -> void:
var camera = get_parent().get_node("MainCamera")
self.connect("laser_shoot", camera, "_on_Player_laser_shoot")

var game = get_parent()
self.connect("player_died", game, "_on_Player_player_died")

Now not these signals are connected through code, we should disconnect them in the Godot interface. With the Game scene open, select the Player node. Go to the signals section and right-click the signals and select "Disconnect All" for each one.

Go back to the Game script. Make sure you're calling these new functions in the "_restart_game" function. We also want to reset our game over check here. At this point the function should look like this.

func _restart_game():
    _undo_game_over()
    _respawn_player()
    is_game_over = false

Go ahead and run the project. We changed some things so let's check everything is still working as expected. Shooting the laser should still give that subtle screen shake effect. When the player dies, the game over screen should still appear. And when we click space bar after the player dies, the gameplay music should resume and player should respawn.

But as you can see, there are few more details to fix before our game is properly restarted. The AsteroidSpawner still has to be restarted and the score needs to be reset. We will handle these operations in those respective nodes.

Open up the AsteroidSpawner script. We will restart the timers and set our variables to their initial values.

func restart():
    $SpawnTimer.stop()
    $DifficultyTimer.stop()
    asteroid_spawn_interval = 2
    difficulty_index = 1.5
    $SpawnTimer.start()
    $DifficultyTimer.start()

Now open up Score script in our GUI. We will reset the score.

func reset():
    text = str(0)

Don't forget to actually call these new functions.

func _restart_game():
    _undo_game_over()
    _respawn_player()
    $AsteroidSpawner.restart()
    $GUI/MarginContainer/HBoxContainer/VBoxContainer/Score.reset()
    is_game_over = false

And with that, the game should fully restart when we click space bar after dying. Run the project and try it out!

Epilogue

This concludes the Space Asteroid Aracade Shooter tutorial. If you made this far, I want to thank you for following along. I hoped that you learned some useful things about Godot and programming in general.

Moving forward, my challenge to you is expand upon what we built so far. Add some more features to the game.

What did you think of this tutorial? I would love to hear your thoughts in the comment section down below.

Buy Me a Beer

Gamedev is my passion. I make these tutorials because I love it. If you found this tutorial helpful, consider sending me a donation.

And if now is not a good time, leave a comment instead. I love hearing your feedback.

16 Comments

  1. Avatar
    oldhat
    February 7, 2020

    Useful tutorial, thank you.

    Reply
  2. Avatar
    Daniel
    March 5, 2020

    Hi,

    When we remove the laser/asteroid upon collision, we remove it from the scene tree and from memory.

    get.parent().remove_child(self)
    queue_free()

    But when we remove the object when it exits the screen, we only remove it from memory

    queue_free()

    and removing it from the scene tree causes errors.

    Why does this error happen in one case and not the other?

    Thanks, this is a great tutorial. This is the best one that I have found.

    Reply
    1. Diego
      Diego
      March 5, 2020

      Hey Daniel,

      The VisibilityNotifier2D signal gets triggered even when we remove it from the scene tree, not just when it physically leaves the edge of the viewport. So when we remove the asteroid from the scene tree in the “explode” function, the VisibilityNotifier2D signal fires.

      When the laser hits the asteroid, we make those if-statement checks to make sure we are not calling functions upon an already-freed object. But if you had the VisibilityNotifier2D signal function like so, you would be attempting to remove the asteroid from the scene tree a second time, after it has already been removed and queued to be freed.

      func _on_VisibilityNotifier2D_viewport_exited(viewport: Viewport) -> void:
      	get_parent().remove_child(self)
      	queue_free()

      Hopefully that helps.

      Reply
  3. Avatar
    Stanislav
    April 5, 2020

    Best tutorial for beginners i’ve found!
    Great thanks!

    Reply
    1. Diego
      Diego
      April 6, 2020

      I’m glad you think so! Let me know if you have any questions.

      Reply
  4. Avatar
    laurent
    April 7, 2020

    Great tutorial

    small error : player_scene is not define. at the end of the tutorial
    i added it at the beginning of Game.gd
    var player_scene=load(“res://characters/Player.tscn”)

    Reply
    1. Avatar
      Fauzi
      August 7, 2020

      I had this issue too, and was wondering if I followed the instructions wrongly. I figured out I need to add this eventually, but I should have read the comments before going through all that.

      Thanks anyway to you and to Diego. This was fun to learn and I think I can embark on making a basic game.

      Reply
  5. Avatar
    Shajid
    April 17, 2020

    Awesome tutorial, thank you for the hard work.

    Reply
  6. Avatar
    Yo1
    May 5, 2020

    Great tutorial and videos.
    I’ve learnt some many things and tricks.
    First time, I feel confident and ready to do something on my own after following a tutorial.

    Thank you

    Reply
    1. Diego
      Diego
      May 5, 2020

      I’m really glad to hear that. I hope you’re now able to start working on a project of your own.

      Reply
  7. Avatar
    Paul
    August 16, 2020

    Hi. Beginner to Godot, good tutorial so far – though whenever I shoot a laser, it appears far from the player (about half a screen to the right of the player) . I still can’t figure out why, as the LaserWeapon is defined in the same location as the player and everything is that coordinate 0,0 as far as I know, unless I’m missing something?

    Reply
    1. Diego
      Diego
      August 31, 2020

      Hey Paul,

      As you correctly guessed, a bug like that means something is not at (0, 0). Check every node and child node in your scenes. Make sure they are all at (0, 0). Only an instance of a scene should have it’s position changed, not the scene itself.

      Reply
  8. Avatar
    Ian
    September 10, 2020

    This is definitely the best beginner tutorial for Godot I have found. It is very well written and covers the complete end-to-end process of writing a fully functioning game. My only comment is with the last chapter, resetting the game does not seem as elegant as the rest. The removing of the signals and deep referencing of nodes ($GUI/MarginContainer/HBoxContainer/VBoxContainer/Score as Label).reset()) seems overly complex and brittle. Would it not be better to simply emit a reset signal and then the various components would listen and respond, such as the score resetting to zero.

    I guess I need to read more about the best Godot design/application architecture practices.

    Reply
    1. Diego
      Diego
      September 13, 2020

      Hey Ian,

      Thanks for the kind words. I agree that the resetting could use improvement. Emitting a single reset signal does sound better.

      Reply
  9. Avatar
    Balázs
    September 13, 2020

    Hi!

    Great tutorial, thank you! I hope you can create more content like this with more advanced topics (e.g. games with more levels, different types of games, etc. ). A twitter account would be great, too, so it would be easier to follow for updates. 🙂

    Also, I think I’ve found a little bug in the game and a solution for it.

    First of all, when the player dies we only call $AsteroidSpawner/SpawnTimer.stop() and not $AsteroidSpawner/DifficultyTimer.stop(), so the counter still increases over time for nothing.

    More importantly, when the player dies after a long session and restarts the game, a high amount of asteroids will spawn for a few seconds, then it will normalize again. I suppose we don’t want it to work like this, so the solution is to modify AsteroidSpawner.gd. In the restart() function insert the following line: $SpawnTimer.wait_time = float(asteroid_spawn_interval) / float(difficulty_index)

    So basically:

    func restart():
    $SpawnTimer.stop()
    $DifficultyTimer.stop()
    asteroid_spawn_interval = 2
    difficulty_index = 1.5
    $SpawnTimer.wait_time = float(asteroid_spawn_interval) / float(difficulty_index)
    $SpawnTimer.start()
    $DifficultyTimer.start()

    That’s it. I hope it will help others too. 🙂

    Reply
    1. Diego
      Diego
      September 13, 2020

      Hey Balázs,

      Nice find on the bugs. That’s one of those bugs that I didn’t find during development since I was either immediately closing the game or immediately restarting. And thanks for posting the fix as well.

      I’ve been wanting to get back into making Godot content. It was a grind, but I really enjoyed making this tutorial.

      Reply

Leave a Reply

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

Scroll to top