Skip to content

Making a 3D Game

This content is not available in your language yet.

This is a guide to making a 3-dimensional game in Godot. If you are unfamiliar with Godot, check out the Godot basics doc and the 3D fundmentals doc.

We’ll be making a game in which the player defends an objective against constantly spawning waves of enemies, with both a lose and win condition. Designed to act as a foundation for your own ideas, allowing for easy expansion and polish.

Making the project

Set up a Basic 3D project, using the Forward+ Renderer. Create a Node3D as the root, and call it something like ‘World’

Godot new project window

Working with the 3D Viewport

When working with 2D Space, we work on two axes, X and Y. When working in a 3D space, we add a third, Z. In Godot, Y represents up and down, while X and Y represent the two horizontal axes.

While mousing over the 3D game viewport, holding right-click will allow you to fly around the viewport using WASD. You can use the scroll wheel to control your speed. While not holding right-click you can use the scroll wheel to zoom in and out.

Now that you know the basics, let’s get into making our game!

Creating a first-person controller

Let’s set up a basic character controller. Thankfully, Godot makes this easy for us and actually has a template script that’ll let us move around.

Scene Setup

First, let’s give ourselves something to stand on.

  1. Create a Node3D and name it something like ‘World’ or ‘Level’ this’ll be the root of our whole scene.
  2. Create a StaticBody3D and give it two child nodes, a MeshInstance3D and a CollisionShape3D. The mesh provides visuals for our floor, while the collisionShape is what we actually stand on.
  3. Select the MeshInstance3D and over on the right, in the inspector, assign its Mesh property to be a PlaneMesh
  4. Select the CollisionShape3D and set its ‘Shape’ property to a new BoxShape3D
  5. Select CollisionShape3D in the scene tree and use the orange dots in the viewport to shape the BoxShape3D to the same shape and size as the plane. Although it’s good to leave it a little thicker than the plane, to stop us from falling through.
  6. Click on the StaticBody3D and find the Transform over on the right. Increase any of the Scale values to something like 20. They’ll all increase as they’re ‘linked’ (Denoted by the chain on the right)

Great! Now our player has something to stand on. Let’s add our Character.

Adding the Character

  1. Create a CharacterBody3D as a child of our root world node. right-click on it, and save it as a new scene. This allows us to easily modify our player. Name it something like ‘Player’ or ‘Character’
  2. Find the newly created scene in the file explorer, or click on the “scene” icon, to open our scene.
  3. Give it a Camera3D and a CollisionShape3D as children. Given the collisionShape a capsule shape using the inspector. The CollisionShape is what’ll allow us to collide with the floor.
  4. right-click on the CharacterBody3D and assign a script, leave everything as default and hit load. This is because we’re using the script Godot provides for us. For now we won’t be messing with this, you can click 3D at the top to return to the scene view.
  5. Let’s click on the Camera3D over on the left, and use the green arrow in the viewport to move it on the Y-axis to wherever you think the ‘eyes’ of your character should be, based on the capsule.
  6. Make sure you save the scene, using CTRL + S or using the File menu in the top left.
  7. Let’s go back to our main scene now, using the tab with the name you used for your ‘World’ Scene. You’ll probably notice that the player is halfway in the floor, which is not ideal. Just click on the root CharacterBody3D Node and move to up on the Y-Axis

As a final touch, let’s add a DirectionalLight3D and rotate it to face downwards so that we can see!

Great! Let’s test our game! Hit the Run Project Button and try moving around! You’ll probably notice two things:

  1. We can’t look around
  2. The default controls use the arrow keys to move, when WASD is generally standard.

Don’t worry, we’ll fix these issues shortly.

StaticBody vs RigidBody?

You may have noticed we used a StaticBody3D Object. If you’ve ever used a 3D game engine before, you’ve likely heard of ‘Static Bodies’ and ‘Rigid Bodies’ but what’s the difference?

Both exist as part of the games Physics simulation, and are capable of physically affecting other objects. The main difference is how they’re affected by other objects.

A Static Body cannot be moved by any other physics object, hence ‘static’ (Though scripts can still move them) Think a solid wall, or tree. Most objects in a given scene that want a player to collide with, will be Static Bodies.

A Rigid Body is the opposite, and can bounce and fall and move based on collisions. If one rigidbody hits another, it’ll make it move, based on things like velocity and gravity. Think a soccer ball colliding with another.

Some things to try

  1. Try deleting the collision shape from the ground or the player, what happens?

  2. Open up the Script for the character movement, try changing the speed up or down, what happens?

Improving the character controller

Changing the Controls

First, let’s fix our control scheme. If you want, you can leave your controls as the arrowkeys, or make them whatever you like. But it’s a good idea to understand how to change them. Thankfully, Godot has a robust Input System. Let’s open our input settings by going to Project > Project Settings… > Input Map from the top left menu. Using the ‘Add new Action’ field, add five actions. ‘Forward’, ‘Backward’, ‘Left’, ‘Right’, and ‘Jump’

Using the ’+’ button next to each direction, search for the key you want to assign for each. Let’s do ‘W’, ‘S’, ‘A’, ‘D’, and ‘Space’ respectively. Makes sure you’re not holding shift or control, otherwise these will become part of the input.

Great! But if you hit play now, you’ll notice nothing has changed. This is because we need to tell our controller script to listen for these inputs.

Let’s go back to our character controller script and change out the default inputs, to our new ones.

Let’s change

var input_dir = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")

to

var input_dir = Input.get_vector("Left", "Right", "Forward", "Backward")

and

if Input.is_action_just_pressed("ui_accept") and is_on_floor():

to

if Input.is_action_just_pressed("Jump") and is_on_floor():

Great! Run your game and test these changes, make sure every movement input, and jump works, and double-check any that don’t.

In the future, if you want to change the controls, all you’ll have to do is change the assigned inputs under the Input Map.

Fixing the Camera

The final thing we need to do to have a fully functional Character controller, is to get our camera moving!

If we were working on a large long-term project we would likely want to make this its own script, but let’s keep things simple for now and just add it to our character controller script.

Let’s open our character controller script by clicking on the CharacterBody3D node and opening the script tab.

below the existing variable declarations, let’s add a new line.

@export var camera:Camera3D

The export tag allows us to assign variables/nodes from within the inspector. If you’ve used unity, this works the same as [SerializeField.] We’ll take another look at this soon.

We’ll need two variables to keep track of our rotation, and control our mouse sensitivity, add these underneath the other variable declarations.

var camera_rotation = Vector2(0, 0)
var mouse_sensitivity := 0.005

You’ve probably noticed by now, that when launching the game, the mouse stays visible, when most games ‘capture’ the mouse. Thankfully this is an easy addition using godots Input system.

func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)

The _ready() function is called by godot when the object is first instantiated, so its a great place to do things like this.

Trying running the game now, you’ll notice your mouse is gone! To quit you can alt+tab out, and press stop in the top right menu.

But let’s make sure we can get our mouse back.

func _input(event) -> void:
# If escape is pressed reveal the mouse
if event.is_action_pressed("ui_cancel"):
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)

The _input(event) function is called by godot whenever input is detected, this let’s us check what was pressed. In this case we’ll be checking for ‘ui_cancel’. You may notice that we didn’t create any input called ‘ui_cancel’ This is another one of Godot’s default inputs, and is bound to ‘esc’

Great! Let’s try running our game again, using Esc to get our mouse back.

Because this function checks for input, we can use it to tell if our mouse has moved, as Godot considers this input.

Let’s add some more to the _input(event) function.

if event is InputEventMouseMotion:
# Get how much the mouse has moved and pass it on to the camera_look function
var relative_position = event.relative * mouse_sensitivity
camera_look(relative_position)

meaning the full function would look like this:

func _input(event) -> void:
# If escape is pressed reveal the mouse
if event.is_action_pressed("ui_cancel"):
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
# Get the mouse movement
if event is InputEventMouseMotion:
# Get how much the mouse has moved and pass it on to the camera_look function
var relative_position = event.relative * mouse_sensitivity
camera_look(relative_position)

The event.relative variable tracks the difference in the position of the mouse from the last frame, which tells us how much the mouse has moved. We then multiply this by our sensitivity value.

You’ll notice we’ve called a method called camera_look() that doesn’t exist, we’ll create this now.

func camera_look(movement: Vector2) -> void:
# Add how much the camera has moved to the camera rotation
camera_rotation += movement
# Stop the player from making the camera go upside down by looking too far up and down
camera_rotation.y = clamp(camera_rotation.y, deg_to_rad(-90), deg_to_rad(90))
# Reset the transform basis
transform.basis = Basis()
main_camera.transform.basis = Basis()
#The player and camera needs to rotate on the x and only the camera should rotate on the y
rotate_object_local(Vector3.UP, -camera_rotation.x)
main_camera.rotate_object_local(Vector3.RIGHT, -camera_rotation.y)

This looks complicated, but really what’s happening is:

  1. We keep track of the total amount the player is rotated by
  2. We ensure that the Y rotation can’t go over a certain value, to prevent us doing flips with the camera (In this case the value has to stay between -90 and 90 degrees)
  3. We zero the basis’ of the camera and player, to making adding rotation easy
  4. We rotate the Player on the X-axis, and the Camera on the Y-axis. These are separated. as if we simply rotated our player, as we looked up, our player would lean backwards until it falls over.

Finally, we need to revisit that @export statement. make sure you save your script, then go back to our Player scene. Click on the CharacterBody3D and you’ll notice that over on the right in the inspector, is a slot called ‘Camera’ Just drag and drop our Character’s camera from the left panel, over into the inspector, and our camera is assigned!

When we play our game, we can properly walk and look around! Now it’s time to get some gameplay in our game!

Some things to try

  1. Try increasing and decreasing the Camera sensitivity, change it to a value that feels best for you.
  2. See if you can change the button we use to get our mouse back to something else

Creating an Objective

(Almost) Every game needs an objective! Something for the player to work towards, and to keep them playing. For our game, we’ll be creating something that the player will defend from waves of enemies. In my version of the game, this will be a Crystal, but yours can be anything!

Objective Scene

Let’s start by going back to our main scene, and giving the root node a new child with the Node3D Type. Call it something like “Objective”. Then, like we did with the player, we’ll save it as a new scene, and open it.

Our scene will need a few things:

  1. A StaticBody3D Object, with a CollisionShape3D to prevent the player walking through it
  2. A MeshInstance3D To give the objective a model
  3. An Area3D with a CollisionShape3D to detect when enemies ‘hit’ it. You can think of an Area3D as a way to detect when an object enters an Area, using the CollisionShape3D to define the area

Your MeshIstance3D can be whatever shape you prefer. However make sure your StaticBody3D has a CollisionShape3D that roughly matches your MeshInstance3D.

Leave the CollisionShape3D attached the Area3D as a circle or square, we’ll be coming back to this later.

Your scene hierarchy should look something like this:

Objective Scene

And that’s it! Everything else will be handled by our script!

Health Script

Before we get into our script, let’s think about what we need it to do:

  1. Detect if an enemy touches the objective
  2. If an enemy does, reduce Health
  3. If health reaches 0, do something

Step 1. Will be the most complicated, but thankfully Godot has a lot of features that’ll make it easy for us.

Let’s attach a script to the root node of our Objective, calling it something like “objective_health”

Let’s first of all declare a new variable to track our health.

@export var health = 3

Mine starts at 3, but yours can start at anything (Greater than 0)

Now, let’s start detecting if an enemy has collided with us.

Let’s start by declaring a new function that’ll be called when something enters the Area3D around the Objective.

func entered(area: Area3D):

Here, ‘area’ represents the other object that has entered the objective, as Area3Ds only detect collisions with other Area3Ds. Let’s check if it’s an enemy.

if(area.is_in_group("enemy")):

If it is, let’s subtract health. To keep things simple, for now we’ll just print a message if we’ve died. We’ll expand this to allow for different damage amounts, and an actual lose state, later.

func entered(area: Area3D):
if(area.is_in_group("enemy")):
health = health - 1
if(health <= 0):
print("Game Over!")

Great! Let’s save our script, and that’s it done for now!

Now, we could have just as easily attached the ‘Health’ script to our player, but in this game, our player isn’t our enemy’s target. However, if you want to change that, by the end of this guide, you should be able to figure that out fairly easily!

What’s a group?

Think of a group as a way to, well, group objects in our game. In this case, if we have multiple types of enemies later, we don’t need to check if we’ve collided with any of them, we can simply check if they’re in the ‘Enemy’ group. We’ll go over adding things to groups when we create an enemy.

Signals

We’ve created our function, but what calls it? We could create a convoluted way of checking if another object is in the bounds of our objective, but thankfully for us, our Area3D already registers whenever anything enters it, all we have to do is connect the functions. We’ll do this using signals!

If we click our Area3D and on the right, change from the Inspector to the Node Tab. From here we can access the Node’s signals. Here we have all the Signals this Node can output, we can attach our function to one of these, allowing it to ‘listen’ for the specific condition. Think of it like setting up a radar dish to listen for one very specific sound. In this case, we want the area_entered signal. You’ll notice it outputs an Area3D which is why we set up our function to take an Area3D. Had we not done this, we wouldn’t be able to connect the signal.

  1. Double-click on the area_entered signal.
  2. Select our ‘Objective’ root node.
  3. Click ‘Pick’ on the bottom right
  4. Select our ‘entered’ function.
  5. Click ‘OK’
  6. Click ‘Connect’

If our function isn’t showing as an option, make sure you’ve saved the script, and that the variable input for the function is correct.

Great! Now whenever something enters our objective, it’ll call this script!

Signals are an amazing way to connect scripts without having to store a Node reference, you can even write your own signal outputs! However that’s out of scope for this guide, if you’re interested, read over the Godot Documentation for Signals

Creating Enemies

Let’s start making the antagonists of this game, our enemies! As you’ve hopefully come to expect by now, we’ll start by making a scene!

Although as you’ll soon find out, this is most important for or enemy, as we’ll be creating more instances of this scene through code.

Enemy Scene

Let’s again give the World root node a new child of a Node3D, right-click it, save as scene. Call it something like “enemy_a” or “enemy_1” you can always change the name later. It’s good to get your head around these steps, as you’ll be doing them often in any project you make! This is the last time this guide will go over them in detail, but if you get confused, feel free to look back at one of the earlier steps.

Take a second to try and think what Node’s we’ll likely need for our enemy.

We’re going to need:

  1. Something to visibly represent the enemy.
  2. Something to allow for our enemy to collide with the objective so that they can deal damage.

We don’t necessarily want our enemy to physically collide with anything however, so we’ll skip giving them a Static or Rigid Body.

Here’s how I’ve laid out my Enemy Scene, yours should hopefully look similar! If it doesn’t don’t worry, feel free to change it to look like mine. Don’t worry about mine being a different colour, we’ll be doing that right now!

Enemy Scene

Remember to assign shapes to your MeshInstance3D and your CollisionShape3D

So that the player can differentiate between our enemies and the rest of our level, let’s add a material to make the enemy a different colour. Materials can be used to alter the appearance of meshes in our game, from simple materials that just change colour, to advanced materials that apply custom textures, different material types, or even shaders.

But for now, let’s keep it simple.

Click on the MeshInstance3D and on the right, in the inspector, click on the Material Surface Override Button, in the ‘0’ slot, where it says empty, add a new StandardMaterial3D

Click on the white sphere that’s appeared. Feel free to experiment with any and all of the settings here, but for the colour, click on Albedo. Click on the white Rectangle, and experiment with the colour, i’ll be making my enemy red.

Materials

Enemy Script

Great, we have our enemy, it’s looking good, now we just need to get it moving! Let’s add a script to the root node of the Enemy Scene. Call it ‘Enemy’ or something similar.

But let’s first think about what we’re going to need:

  1. A way to control the health and speed of enemies
  2. A reference to their target (The objective)
  3. To face toward, and then move toward, the objective

The first step should be easy, let’s add some variables! Remember the @export tag we used on the objective script? We’ll be using that here too! Except this time we’ll be using it to allow us to easily change the variables on our enemy, without needing to edit the script.

@export var speed = .5

If you save your script, and look in the inspector of the enemy object, you’ll notice there is now a new box,‘speed’ with it’s current value set to 0.5. You can think of the value we declared like a default value, and anything we type into it will overwrite it.

Let’s add two more variables, which we’ll use later.

var targetNode
var lerp_t = 0
var startPosition

These will only be used inside the script, so there’s no need for them to be ‘exports.’

In our _ready function, we’ll get a reference to our Objective, so that our enemies can target it.

targetNode = get_node("%Objective")
startPosition = global_position

If you haven’t seen it before, the ’%’ here denotes a unique name, to prevent us from having to put in the complete path for the objective, but this would be equivalent to something like “/root/World/Objective” Using the ’%’ is great when we know we’re only going to have one of something! We’ll have to edit our objective slightly to make this work, but we’ll do that in a moment.

For now, let’s focus on our enemy. For this, we’re going to be doing something called ‘lerping’ (Short for Interpolation) lerping is basically a smooth way to move something from one point to another, both in 2D and 3D. All we need for a lerp is that starting position (That’s why we got the enemy’s position in the _ready function) the target position (That’s our objective) and a ‘t’ value, which controls how far between the two points or object is. the t value goes from 0 to 1, with 0 being right at the start, and 1 being right at the end.

Let’s start lerping! In our _process function we’ll add a few lines.

lerp_t += delta * speed
look_at(targetNode.global_position)
global_position = lerp(startPosition, targetNode.global_position, lerp_t)

First, we increase our ‘t’ value, based on Delta (The amount of time since the last frame) and our speed variable, to allow us to control the rate of the lerp.

Next, we ensure our enemy is facing toward the objective, this doesn’t matter as much for my circle enemy, but if you have a more complex shape, it’ll make things look better.

Then, we set the enemies global_position to the value of the lerp between its starting position, and the objective, based on the ‘t’ value. If you’re still confused about Lerps, take a look at the Godot Docs for Interpolation

Then, let’s rap it all in an ‘if’ statement, to stop the lerp once the ‘t’ value as reached 1.

if(lerp_t < 1):

Giving us a final script that looks like this:

extends Node3D
@export var health = 1
@export var speed = .5
var targetNode
var lerp_t = 0
var startPosition
# Called when the node enters the scene tree for the first time.
func _ready():
targetNode = get_node("World/%Objective")
startPosition = global_position
# Replace with function body.
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta):
if(lerp_t < 1):
lerp_t += delta * speed
look_at(targetNode.global_position)
global_position = lerp(startPosition, targetNode.global_position, lerp_t)

Before we move on, there’s a few more things we should do.

Let’s assign that group we were talking about earlier, so that our Objective actually loses health when an enemy reaches it. Go into your Enemy Scene, select the root Node3D, click on ‘Node’ on the right inspector, and open the Groups Tab. Enter “enemy” (case-sensitive, ensure this matches what you used in your objective script) and click add. Click ‘Manage Groups’ and move using the ‘Add’ button, move every node until it’s in the ‘Nodes in Group’ Section. While every node in this scene could be in different groups, or we would just add the ones we need, in a situation like this it’s best to just have every node in the group we want.

You’ll also want to go back to your main scene, right-click on the Objective, and select “Access as Unique Name” this will allow for getting the Objective node with (“%Objective”). But make sure the name here matches exactly the name used in the script.

We probably don’t want our enemies to just sit at the objective forever after they reach it, so let’s make sure we delete them. Let’s go back to our Objective Script and after we reduce health, we’ll delete the enemy. This can be done by calling queue_free() on the parent node of the Enemy. This schedules the node to be deleted at the end of the current frame.

Because our Objective has a reference to the Area3D, rather than the root node, we’ll write it like this:

area.get_parent().queue_free()

Great! Add a couple of copies of your enemy to your game by dragging the scene from the file inspector into the scene. Add at least 3 so we can make sure our lose condition is triggering.

Test your game, and after three enemies reach the objective we should see the ‘Game Over!’

That’s our enemies themselves done, next we’ll make sure they spawn on their own!

Global Position vs Position

You may have noticed us using ‘global_position’ when moving the enemy. Why not just use ‘position’?

This is because ‘position’ refers to the relative position of a Node to it’s root, so modifying this doesn’t actually move the ‘origin’ of the node, it just moves its offset relative to its parent! So if we accessed and modified ‘position’ we might get some unexpected results. Whereas ‘global_position’ is relative to the entire scenes origin (0,0,0) and allows us to more reliably understand where a node is. While there are situations in which we’ll want to modify a ‘position’ in most cases we’ll use ‘global_position’

object.queue_free vs object.free

When deleting an object from our scene, we have two options, the queue_free() function, and the free() function, but what’s the difference? queue_free() schedules a node to be deleted at the end of a frame. Whereas free() deletes it instantly. Your instinct may be to think that surely instant is better! Why wait! But in basically every case, the difference will never be noticeable. Especially when we consider that using queue_free() is safer for our code. If we were to use free() and in the same frame try to call a function on the node (As Godot still thinks it exists until the end of the frame) we would get an error. queue_free() Allows for safe deletion of objects.

This likely would make no difference at all to our project, but it’s a good thing to keep in mind as you move on to bigger things!

Some things to try

  1. Try messing with the speed on your Enemy (Remember, you can change this in the inspector, you don’t need to change the script) until you’re happy with how fast they move.
  2. Try creating an ‘enemy_2’/‘enemy_b’ by making a new scene which reuses the enemy script, but has a different material and speed. (Remember to add it to the group)

Spawning Enemies

Let’s get our enemies spawning on their own, we wouldn’t have much of a game if we had to add every enemy manually!

As you may have come to expect by now, we’ll be making a new scene, again with a Node3D root. Although in this case, that’ll be it! We don’t need our spawner to do anything other than exist in 3D Space with a script. If you want your spawners to be visible, feel free to add a MeshInstance3D but mine will be invisible. Save the scene, call it something like ‘spawner_scene’

Let’s get straight into the scripting! Attach it to the Node3D in your new scene, call it something like ‘spawner_script’

Let’s again have a think about what we’re going to need:

  1. A reference to the enemy Scene so we can create copies of it
  2. Some way to control how often enemies are spawned
  3. To actually spawn the enemy

Nothing too complicated! But definitely some new concepts. We’ll tackle them one at a time!

As always, let’s start by declaring some variables.

@export var spawn_delay_lower = 2
@export var spawn_delay_upper = 4
@export var enemy_scene:PackedScene
var ready_to_spawn = true

We won’t be doing anything in the _ready() function so you can either delete it or just leave it.

Our first two exported vars here will be used to control the spawnrate of our enemies, while allowing for some randomness. Our second export will be our reference to our enemy. When we declare a variable in Godot, we can add ’:” followed by a variable type to allow only that type. This is especially useful for exports. (If not declaring a default value, exports require a type to be given)

ready_to_spawn is simply to keep track of if we’re ready to spawn another enemy yet.

Let’s get straight into the _process function! First things first, let’s check if we’re ready to spawn another enemy.

if(ready_to_spawn):
ready_to_spawn = false
var rng = RandomNumberGenerator.new()

simple enough, if we are ready to spawn, indicate that we’re not ready to spawn, and go onto our spawning logic. We’ll also create a new RandomNumberGenerator so that we can generate a random number within our defined range.

Next, we’ll need to create a Timer and create our very own Signal that calls a function when the timer ends. Sounds scary, but I promise it’s not!

First, let’s make that Timer

var t = get_tree().create_timer(rng.randi_range(spawn_delay_lower, spawn_delay_upper))

This will create a timer, add it to the scene tree (Everything needs to be in the tree) and give it a timer with a length between our lower and upper spawn_delay

Now, let’s connect it to a function.

t.timeout.connect(spawnEnemy)

You’ve made your very own signal like the one we used earlier! This connects our as of yet uncreated function called ‘spawnenemy’ when the timer ends. Giving us a process function that looks like this:

func _process(delta):
if(ready_to_spawn):
ready_to_spawn = false
var rng = RandomNumberGenerator.new()
var t = get_tree().create_timer(rng.randi_range(spawn_delay_lower, spawn_delay_upper))
t.timeout.connect(spawnEnemy)

Let’s make that function, where our actual spawning logic will sit.

First, we need to instantiate (create) an instance of our enemy scene

func spawnEnemy():
var n = enemy_scene.instantiate()
add_child(n)

We create a copy of the node, and importantly, add it to the scene.

Then, let’s set its position, and then flag that we’re ready to create a new enemy

n.global_position = global_position
ready_to_spawn = true

giving us a function that looks like this:

func spawnEnemy():
var n = enemy_scene.instantiate()
add_child(n)
n.global_position = global_position
ready_to_spawn = true

Our script is done, but there are a couple more important things we need to do before it’s ready to test.

  1. Make sure you open the spawner scene and in the inspector drag your enemy scene into the ‘enemy_scene’ field.
  2. Move your spawner from the scene origin to somewhere else in the scene, so that it doesn’t spawn things on top of the objective.

After those steps, we should be good to test! Run your game, and let a few enemies spawn.

Congratulations! Your game now has continuously spawning enemies! Now to destroy them!

Some things to try

  1. Trying messing with the spawn timer variables until you’re happy with their rate of spawning.
  2. Add a few more spawners to your scene, so that enemies spawn from multiple different locations
  3. If you created a second type of enemy, create spawners that create that enemy with a different spawn delay Remember: You don’t need to make a new script, all you need to do is change the scene inside the ‘enemy_scene’ field!

Fighting Back

Our enemies have had nothing to stop them so far, but it’s time for that to change, it’s time to give ourselves a way to destroy them!

To do this, we’ll be using something called a Raycast3D. The easiest way to think about this, is that we’ll be sending out a laser, whenever we click we’ll see if this laser is hitting something. If it is, we’ll destroy it!

Which as you may be able to guess, will mean we’ll need to add something new to our Inputs. In case you’ve forgotten, in the top left, click Project -> Project Settings -> Input Map

Add a new Input called something like “Attack” and assign it to the Left Mouse Button.

Then, let’s head to our Character Scene. Then, as a child of our camera (To make sure it’s always facing the same direction as our camera) add a new Raycast3D node. You’ll now see a short blue line pointing downward in our character scene. To get it pointing forward, in the inspector, set its Target Position to X: 0, Y: 0: Z: -20. If this doesn’t get it point in the same direction as your camera, change around the values until it does. The -20 here determines the range at which our Raycast3D will detect collisions, you’re welcome to adjust this value to be higher or lower to what feels right for your game.

Let’s head to our character script, and create a new export value to store our Raycast3D

@export var raycast:RayCast3D

Then, in the func _input(event) function, let’s add a new if

if(event.is_action_pressed("Attack")):

Here’s what we need to do after the player clicks:

  1. See if our raycast is colliding with anything
  2. See if what it’s colliding with is an Enemy
  3. Destory It

Steps 2 and 3 are easy enough, we’ve done both of those before. To check if the raycast is colliding with something, we’ll use:

if(raycast.is_colliding()):

Then, we just need to do the last two steps.

if(raycast.get_collider().is_in_group("enemy")):
raycast.get_collider().get_parent().queue_free()

Giving us a final statement that looks like this:

if(event.is_action_pressed("Attack")):
if(raycast.is_colliding()):
if(raycast.get_collider().is_in_group("enemy")):
raycast.get_collider().get_parent().queue_free()

Important: Make sure you assign the RayCast3D in the inspector!

Let’s test our game! We should now be able to destroy enemies by clicking on them! Although it’s a little difficult to aim for now… But don’t worry, We’ll be adding a reticule to help us in the next step.

Tying it all together with UI

Let’s start adding some UI to our game!

First let’s think about what we’re going to want:

  1. A reticule, so we can aim
  2. A way to track how many lives we have still available
  3. How much time is left before we win
  4. A label that says if you’ve won or lost.

And that should be all for now, but once we’re done you should know how to add anything else you might want

In your main scene, add a new Control node as a child of the root ‘World’ Node. Rename the node to something like ‘HUD’. Save it as a new scene, call it something like HUD_scene and then open the scene.

Click on the root Control node, and in the inspector, under the Layout section, set its Anchor Preset to ‘Center’

As a child of the root Control Node, add a ColorRect. This will be our Reticule. Open the inspector, set its colour to whatever you want (Although Ideally not white)

Note: If you have an image you want to use as the reticule instead, use the TextureRect node

Now, in its inspector, under the Transform tab, set both Size values to 5px, and both Pivot offset values to 5px. Finally, under the Layout tab, set the layout mode to Anchors and the Anchors Preset to Center These will all ensure the reticule is in the exact center of the screen.

Run the game, and you’ll notice aiming is much easier now!

Great, now let’s go back to our HUD scene. Add four Label nodes, all children of the root node. Name them, ‘life_label’, ‘life_val’, ‘time_label’, and ‘time_val’ respectively. Let’s put the two ‘life’ labels in the top left (You can move them just by dragging by entering Move mode by pressing ‘W’) and the two ‘time’ labels to the top right.

Now we’ll set the text, this is done in the inspector in the Text field.

Make ‘life_label’ say “Lives:”

Make ‘life_val’ say “3”

Make ‘time_label’ say “Time Remaining:”

Make ‘time_val’ say “000” (We’ll set this via scripting later.)

Now, let’s add one final Label, call it something like ‘winlose_label’ and make it say “You Lose!” In the inspector, Set its Anchors Preset to Center the same way you did for the reticule. Under the Theme Overrides section in Inspector, open the font sizes tab, and set the font size to something like 50px. Then, under the Visibility Section in the Inspector, untick the Visible box to hide it. (We’ll make it visible via a script)

Now set them up in the scene, mine look like this:

HUD scene

Let’s create a simple script to let us easily change the values of our labels.

Attach a script to the root Control node and call it something like ‘HUD_manager’

As you’ve probably come to expect, let’s add some exports to access our ‘val’ labels

@export var life_val_lbl:Label
@export var time_val_lbl:Label
@export var winlose_lbl:Label

We’ll add three simple functions that update these. Because these are so simple, we won’t bother going over them line by line.

For the Life Label:

func update_life(life:String):
life_val_lbl.text = life

For the Time Label:

func update_time(time:String):
time_val_lbl.text = time

and for the Win/Lose label:

func show_win_lose(text:String):
winlose_lbl.text = text
winlose_lbl.visible = true

With the only real difference here being that we make the Win/Lose label visible when we update it.

Important: Before we move on, make sure you set the Export vars in the inspector!

Great! Now let’s get these connected. For now we’ll just connect the updateLife function and make the text appear when we lose. We’ll be adding the timer (and therefore winning) in the next step.

For this, we’ll be updating our objective_health script.

First of all, let’s add a new variable to store a reference to our HUD, and in the _ready function, assign this. We’ll also assign the displayed Health text here based on our Exported health variable, so that the two always match.

var HUD
func _ready():
HUD = get_tree().root.get_node("World/HUD")
HUD.update_life(str(health))

Then, in the entered() function we wrote earlier, we’ll do two things:

  1. Update the label whenever we take damage, to the new health value
  2. If we reach 0 health, display “You Lose!” (We can remove the Print() now)
HUD.update_life(str(health))
HUD.show_win_lose("You Lose!")

for a function that looks like this all together:

func entered(area: Area3D):
if(area.is_in_group("enemy")):
health = health - 1
area.get_parent().queue_free()
HUD.update_life(str(health))
if(health <= 0):
HUD.show_win_lose("You Lose!")

If you run the game now, you should see our health decreasing whenever an enemy reaches the object, and the “You lose!” text showing up when our health reaches zero!

Now, let’s add a timer so we can actually win!

Winning

Our game’s win condition is going to be surviving until a timer runs out, without losing beforehand. For this, as you may have guessed, we’ll use a Timer again, just like we did with the spawners.

However, we don’t really need our timer to have its own scene, as we’ll only have one in our scene. This makes this an excellent time to talk about Autoload scripts! Which are Godot’s equivalent to Singletons if you’re familiar from other Languages.

To create an Autoload we’ll open our Project Settings and tab over to the Autoload tab. In the Node Name Section, call it something like “game_timer” and confirm to create the script. Clicking on the “res://game_timer.gd” field will open our script.

First, let’s set up our variables.

var game_length = 60
var HUD
var timer

Our first variable here will determine how many seconds it takes before we win the game. Something to keep in mind, because Autoload scripts don’t have scenes, we can’t use Exported variables. Our other two variables will be assigned in our _ready() function, which we’ll set up now:

func _ready():
HUD = get_tree().root.get_node("World/HUD")
timer = get_tree().create_timer(game_length)
timer.timeout.connect(win_game)

First, as we did in our Objective, we’ll get a reference to our HUD. Then we’ll create a timer with a length set by our game_length variable. Then connect it to a function that we’ll create now.

func win_game():
HUD.show_win_lose("You Win!")
start_close_game()

This function displays “You Win!” on our UI, and then calls another function we’ll create now, which will close the game after a delay.

func start_close_game():
var tq = get_tree().create_timer(2)
tq.timeout.connect(get_tree().quit)

Here we again create a new timer, but this time, attach it to a built in Godot function which will close the game. In this case, the game will close after 2 seconds.

Finally, we need to update the text on our time_label to reflect the amount of time left on our timer. Thankfully, Godot makes this easy for us. We’ll do this directly in the _process(delta) function.

first, let’s get the remaining time, and covert it to a string.

var time = str(timer.get_time_left())

We could just leave our variable here, but it would have a lot of extra characters after the decimal place which will go off the edge of the screen, so let’s trim it before we update the label.

time = time.erase(5, 15)
HUD.update_time(time)

This trims any characters after the 5th (Which keeps two decimal places) and then updates the text.

Our final step is to make sure our game also closes after two seconds when we lose. Because we’ve made our timer an Autoload it can be accessed easily from anywhere! Let’s go to our Objective script.

And simply add

GameTimer.start_close_game()

Directly after the line where we update the label to say “You Lose!”

And that’s it! We have a fully functioning 3D game with gameplay, UI, a win condition, and a lose condition!

Congratulations, you’ve just made your very own 3D game!

Final Touches

If you like, you can leave your game there, it’s a fully functional game, and if you’ve made it this far you should be proud of yourself! But, if you want to polish it up a little, here are some ideas, although you’ll be on your own!

  1. Modify the level to include walls to prevent the player from falling, as well as platforms to jump on.
  2. Add extra spawners
  3. Add more enemy types
  4. Have the enemy spawners scale up their spawnrate (Remember, you can easily access the gametimer from anywhere)

If you want to look over my version of the project, you can access the Github repository here: Link

Remember, if you’re ever lost, the Godot Documentation is the best place to start!

Where to next

From here, you can make the project your own! Add your own models, music, and textures. Add whole new mechanics! Look into gamefeel concepts and ‘juice’ the game up!

I can’t wait to see what you make!