Create your first 3D Game from Zero
2025/04/21

- Type
- Learning Resource
- Format
- Video
- Version
- Godot 4.x
- Downloadable Demo
- FAQ/Troubleshooting
- Bonus
- Created
- Updated
- 2025/03/03
- 2025/04/21
Welcome! This free beginner Godot course will teach you how to code your first complete 3D game, step-by-step, from start to finish.
You will learn how to create a 3D FPS arena survival game with a player character that can move, jump, and shoot, and enemies that chase and push you off the platforms. You will also learn how to create a level, add sound effects, and export the game to share it with others.
This video series is for people who are new to Godot and gamedev and want to get their feet wet.
To fully understand the code, it's helpful to know a little bit of coding. Any language will do. If you're completely new to coding, you can check out our free web app: Learn GDScript From Zero. It'll introduce you to GDScript, the programming language we use in this video series.
Bear in mind that when you learn Godot's GDScript, you're not stuck with it! Your coding knowledge is transferable to any other programming language like C#. We teach GDScript because it's particularly easy to pick up and it's well-integrated in Godot, so it's a great way to get started.
This video comes with two zip files:
Below each video, you will also find a folded code reference to check your scripts against without having to leave the page.
GDScript, the game scripting language we use in this tutorial, has an optional syntax called type hints. It's a way to tell the computer what type of data a variable or a function uses (a number, text, a node, etc.). By default, type hints are turned on in Godot 4.
So when you type func _process
in a script, Godot will automatically add type hints by default:
func _process(delta: float) -> void:
In this beginner tutorial, we don't use type hints to keep the code simple and easy to follow. If you have type hints enabled in your project settings, you can turn them off to follow along with the videos.
Go to Editor
The scene that plays when you click the button or press Play Main Scenef5 (on Mac: ⌘b) is the project's main scene. If you see a different scene playing than the video, it means that your project's main scene is set to run a different scene file.
There are two common ways to change the project's main scene:
You can also look for a scene file (a file that ends with ".tscn") in the FileSystem dock, right-click it, and select Set as Main Scene. You will see the filename turn blue to indicate that this scene is the one that will run when running your project.
At the end of this section, you should be able to verify that:
In the player scene, the node has its CharacterBody3DTransform
If you see an error message like this when running the game: Invalid access to property or key 'rotation_degrees' on a base object of type 'null instance'., it means that the engine could not get the node you're trying to access.
In this example, this error could occur on this line:
%Camera3D.rotation_degrees.x -= event.relative.y * 0.2
We're trying to change the node's rotation, but the engine can't find the Camera3D node. Camera3D
There are two common causes for this error:
%
shortcut in the script, the node must have a unique name in the scene. Right-click the node in the Scene dock and select . Access as Unique Name%Camera3D
. If instead you rename the node to , you must change the script to PlayerCamera%PlayerCamera
.In the player scene, the root Player node's Transform
Here's the complete code for the script at the end of part 2:
extends CharacterBody3D
func _unhandled_input(event):
if event is InputEventMouseMotion:
rotation_degrees.y -= event.relative.x * 0.5
%Camera3D.rotation_degrees.x -= event.relative.y * 0.2
%Camera3D.rotation_degrees.x = clamp(
%Camera3D.rotation_degrees.x, -60.0, 60.0
)
Here's the complete code for the script at the end of part 3:
extends CharacterBody3D
func _unhandled_input(event):
if event is InputEventMouseMotion:
rotation_degrees.y -= event.relative.x * 0.5
%Camera3D.rotation_degrees.x -= event.relative.y * 0.2
%Camera3D.rotation_degrees.x = clamp(
%Camera3D.rotation_degrees.x, -60.0, 60.0
)
elif event.is_action_pressed("ui_cancel"):
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
If you have issues with the player's movement, in particular, if it's not aligned with the camera, this can mean that a node is offset or rotated in the player scene while it shouldn't.
Review the Player and nodes in the player scene. In the Inspector, locate the Camera3DTransform
Also, make sure that the Transform
More generally, review the checkpoint in each section to help you identify what might be wrong and what the needed values for this project are.
In the Project
Here's the complete code for the script at the end of part 4:
extends CharacterBody3D
func _unhandled_input(event):
if event is InputEventMouseMotion:
rotation_degrees.y -= event.relative.x * 0.5
%Camera3D.rotation_degrees.x -= event.relative.y * 0.2
%Camera3D.rotation_degrees.x = clamp(
%Camera3D.rotation_degrees.x, -60.0, 60.0
)
elif event.is_action_pressed("ui_cancel"):
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func _physics_process(delta):
const SPEED = 5.5
var input_direction_2D = Input.get_vector(
"move_left", "move_right", "move_forward", "move_back"
)
var input_direction_3D = Vector3(
input_direction_2D.x, 0, input_direction_2D.y
)
var direction = transform.basis * input_direction_3D
velocity.x = direction.x * SPEED
velocity.z = direction.z * SPEED
move_and_slide()
In the Project1920
and the Viewport Height is set to 1080
. Also, the Stretch
Here's the complete code for the script at the end of part 4. I highlighted the new lines of code for the jump mechanic:
extends CharacterBody3D
func _unhandled_input(event):
if event is InputEventMouseMotion:
rotation_degrees.y -= event.relative.x * 0.5
%Camera3D.rotation_degrees.x -= event.relative.y * 0.2
%Camera3D.rotation_degrees.x = clamp(
%Camera3D.rotation_degrees.x, -60.0, 60.0
)
elif event.is_action_pressed("ui_cancel"):
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func _physics_process(delta):
const SPEED = 5.5
var input_direction_2D = Input.get_vector(
"move_left", "move_right", "move_forward", "move_back"
)
var input_direction_3D = Vector3(
input_direction_2D.x, 0, input_direction_2D.y
)
var direction = transform.basis * input_direction_3D
velocity.x = direction.x * SPEED
velocity.z = direction.z * SPEED
+ velocity.y -= 20.0 * delta
+ if Input.is_action_just_pressed("jump") and is_on_floor():
+ velocity.y = 10.0
+ elif Input.is_action_just_released("jump") and velocity.y > 0.0:
+ velocity.y = 0.0
move_and_slide()
In the game scene, all the nodes have their Use Collision property turned on in the Inspector CSGBox3D
Correction: In the video, in the bullet scene, we turn the Projectile node 90 degrees on the Y axis to make it face forward. However, due to how the bullet is coded, it ends up facing backwards.
This is not very noticeable when playing so it's fine to leave it as-is, but in the checkpoint below I've corrected the angle value to -90 degrees to make the projectile model face the right way.
At the end of this section, you should be able to verify that:
In the player scene, there is no leftover bullet
In the bullet scene, the child Projectile has its Transform
Here's the complete code for the script at the end of part 7:
extends Area3D
const SPEED = 55.0
const RANGE = 40.0
var travelled_distance = 0.0
func _physics_process(delta):
position += transform.basis.z * SPEED * delta
travelled_distance += SPEED * delta
if travelled_distance > RANGE:
queue_free()
Here's the player script, . I highlighted the new lines of code for the shooting mechanic:
extends CharacterBody3D
func _unhandled_input(event):
if event is InputEventMouseMotion:
rotation_degrees.y -= event.relative.x * 0.5
%Camera3D.rotation_degrees.x -= event.relative.y * 0.2
%Camera3D.rotation_degrees.x = clamp(
%Camera3D.rotation_degrees.x, -60.0, 60.0
)
elif event.is_action_pressed("ui_cancel"):
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func _physics_process(delta):
const SPEED = 5.5
var input_direction_2D = Input.get_vector(
"move_left", "move_right", "move_forward", "move_back"
)
var input_direction_3D = Vector3(
input_direction_2D.x, 0, input_direction_2D.y
)
var direction = transform.basis * input_direction_3D
velocity.x = direction.x * SPEED
velocity.z = direction.z * SPEED
velocity.y -= 20.0 * delta
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = 10.0
elif Input.is_action_just_released("jump") and velocity.y > 0.0:
velocity.y = 0.0
move_and_slide()
+ if Input.is_action_pressed("shoot") and %Timer.is_stopped():
+ shoot_bullet()
+func shoot_bullet():
+ const BULLET_3D = preload("res://player/bullet_3d.tscn")
+ var new_bullet = BULLET_3D.instantiate()
+ %Marker3D.add_child(new_bullet)
+
+ new_bullet.transform = %Marker3D.global_transform
+
+ %Timer.start()
Before we add the colors to the bullet mesh, the different meshes can be a bit difficult to see depending on the shading applied to the scene.
If when loading a mesh, you don't really see highlights and shadows on it, double check that the and Toggle preview sunlight icons are activated in the toolbar above the viewport. Toggle preview environment
These two icons control the scene's preview lighting. If they are turned off, the scene will have no lighting and will generally turn a bit dark gray (unless it's a game level and it contains light nodes), which makes it hard to see the different meshes.
Here's the complete code for the script at the end of this section:
extends Node3D
@onready var animation_tree = %AnimationTree
func hurt():
animation_tree.set("parameters/OneShot/request", true)
Here is the mob script, :
extends RigidBody3D
@onready var bat_model = %bat_model
func take_damage():
bat_model.hurt()
Finally, here's the updated bullet script, :
extends Area3D
const SPEED = 55.0
const RANGE = 40.0
var travelled_distance = 0.0
func _physics_process(delta):
position += transform.basis.z * SPEED * delta
travelled_distance += SPEED * delta
if travelled_distance > RANGE:
queue_free()
func _on_body_entered(body):
queue_free()
if body.has_method("take_damage"):
body.take_damage()
In Godot 4.4, there's a bug where when creating an node, the AnimationTree bottom panel does not automatically expand. You have to open it manually by clicking the AnimationTree label at the bottom of the editor. AnimationTree
Here's the complete code for the script at the end of this section. I highlighted the new lines of code for the mob to follow the player:
extends RigidBody3D
+var speed = randf_range(2.0, 4.0)
@onready var bat_model = %bat_model
+@onready var player = get_node("/root/Game/Player")
+func _physics_process(delta):
+ var direction = global_position.direction_to(player.global_position)
+ direction.y = 0.0
+ linear_velocity = direction * speed
+ bat_model.rotation.y = Vector3.FORWARD.signed_angle_to(direction, Vector3.UP) + PI
func take_damage():
bat_model.hurt()
In this game, we use a node for mobs (for the bat). A RigidBody3D is largely controlled by the physics engine and you influence it through collisions and forces. RigidBody3D
By default, a rigid body 3D node is set to automatically rotate under the influence of impacts and forces. When you collide with a bat with the player character this creates an impact that causes the bat to start spinning. The same happens when bats collide with one another.
It's a behavior that we want when killing a bat because we want it to roll on the floor and rolling on the floor is powered by the same system.
For simplicity in the video, we keep the rotation by default, but if you want to suppress this behavior while the bats are flying around, you can lock the mob's rotation by turning on the Deactivation
This will, however, cause the bats not to roll anymore when killing them. So to make them roll upon dying, in the script, you need to add a line of code to remove rotation lock. You can add the following lines at the end of the take_damage()
function for that:
func take_damage():
if health <= 0:
return
bat_skin.hurt()
health -= 1
hurt_sound.pitch_scale = randfn(1.0, 0.1)
hurt_sound.play()
if health == 0:
set_physics_process(false)
gravity_scale = 1.0
var direction_back = player.global_position.direction_to(global_position)
var random_upward_force = Vector3.UP * randf() * 5.0
apply_central_impulse(direction_back.rotated(Vector3.UP, randf_range(-0.2, 0.2)) * 10.0 + random_upward_force)
timer.start()
+ lock_rotation = false
In the mob scene, the node is set to One Shot Timer
The node's Timertimeout
signal is connected to the Mob node's _on_timer_timeout
function
Here's the complete code for the script at the end of this section. I highlighted the new lines of code for the mob to follow the player:
extends RigidBody3D
+var health = 3
var speed = randf_range(2.0, 4.0)
@onready var bat_model = %bat_model
@onready var timer = %Timer
@onready var player = get_node("/root/Game/Player")
func _physics_process(delta):
var direction = global_position.direction_to(player.global_position)
direction.y = 0.0
linear_velocity = direction * speed
bat_model.rotation.y = Vector3.FORWARD.signed_angle_to(direction, Vector3.UP) + PI
func take_damage():
+ if health <= 0:
+ return
bat_model.hurt()
+ health -= 1
+
+ if health == 0:
+ set_physics_process(false)
+ gravity_scale = 1.0
+ var direction = player.global_position.direction_to(global_position)
+ var random_upward_force = Vector3.UP * randf() * 5.0
+ apply_central_impulse(direction.rotated(Vector3.UP, randf_range(-0.2, 0.2)) * 10.0 + random_upward_force)
+ timer.start()
+func _on_timer_timeout():
+ queue_free()
Here's the complete code for the script at the end of this section. I highlighted the new lines of code for the mob to follow the player:
extends Node3D
@export var mob_to_spawn: PackedScene = null
@onready var marker_3d = $Marker3D
@onready var timer = %Timer
func _on_timer_timeout():
var new_mob = mob_to_spawn.instantiate()
add_child(new_mob)
new_mob.global_position = marker_3d.global_position
In the game scene, the MobSpawner3D node's mob_spawned
signal is connected to the Game node
Here's the complete code for the script at the end of this section. I highlighted the new lines of code to emit a signal when a mob spawns.
extends Node3D
+signal mob_spawned(mob)
@export var mob_to_spawn: PackedScene = null
@onready var marker_3d = $Marker3D
@onready var timer = %Timer
func _on_timer_timeout():
var new_mob = mob_to_spawn.instantiate()
add_child(new_mob)
new_mob.global_position = marker_3d.global_position
+ mob_spawned.emit(new_mob)
Here's the updated mob script, , with the new lines to track death and emit a signal:
extends RigidBody3D
signal died
var health = 3
var speed = randf_range(2.0, 4.0)
@onready var bat_model = %bat_model
@onready var timer = %Timer
@onready var player = get_node("/root/Game/Player")
func _physics_process(delta):
var direction = global_position.direction_to(player.global_position)
direction.y = 0.0
linear_velocity = direction * speed
bat_model.rotation.y = Vector3.FORWARD.signed_angle_to(direction, Vector3.UP) + PI
func take_damage():
if health <= 0:
return
bat_model.hurt()
health -= 1
if health == 0:
set_physics_process(false)
gravity_scale = 1.0
var direction = player.global_position.direction_to(global_position)
var random_upward_force = Vector3.UP * randf() * 5.0
apply_central_impulse(direction.rotated(Vector3.UP, randf_range(-0.2, 0.2)) * 10.0 + random_upward_force)
timer.start()
+ died.emit()
func _on_timer_timeout():
queue_free()
Here's the game script, :
extends Node3D
var player_score = 0
@onready var label := %Label
func increase_score():
player_score += 1
label.text = "Score: " + str(player_score)
func _on_mob_spawner_3d_mob_spawned(mob):
mob.died.connect(increase_score)
In the player scene, there is an node (which plays at a constant volume), and in the mob scene, there are AudioStreamPlayer nodes for the hurt and KO sounds (so their volume fades with the distance to the player) AudioStreamPlayer3D
And here's the player script, . I highlighted the added line of code for playing the shooting sound:
extends CharacterBody3D
func _unhandled_input(event):
if event is InputEventMouseMotion:
rotation_degrees.y -= event.relative.x * 0.5
%Camera3D.rotation_degrees.x -= event.relative.y * 0.2
%Camera3D.rotation_degrees.x = clamp(
%Camera3D.rotation_degrees.x, -60.0, 60.0
)
elif event.is_action_pressed("ui_cancel"):
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func _physics_process(delta):
const SPEED = 5.5
var input_direction_2D = Input.get_vector(
"move_left", "move_right", "move_forward", "move_back"
)
var input_direction_3D = Vector3(
input_direction_2D.x, 0, input_direction_2D.y
)
var direction = transform.basis * input_direction_3D
velocity.x = direction.x * SPEED
velocity.z = direction.z * SPEED
velocity.y -= 20.0 * delta
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = 10.0
elif Input.is_action_just_released("jump") and velocity.y > 0.0:
velocity.y = 0.0
move_and_slide()
if Input.is_action_pressed("shoot") and %Timer.is_stopped():
shoot_bullet()
func shoot_bullet():
const BULLET_3D = preload("res://player/bullet_3d.tscn")
var new_bullet = BULLET_3D.instantiate()
%Marker3D.add_child(new_bullet)
new_bullet.transform = %Marker3D.global_transform
%Timer.start()
+ %AudioStreamPlayer.play()
Here's the updated mob script, , with the lines of code to play the hurt and KO sounds highlighted:
extends RigidBody3D
signal died
var health = 3
var speed = randf_range(2.0, 4.0)
@onready var bat_model = %bat_model
@onready var timer = %Timer
@onready var player = get_node("/root/Game/Player")
+@onready var hurt_sound = %HurtSound
+@onready var ko_sound = %KOSound
func _physics_process(delta):
var direction = global_position.direction_to(player.global_position)
direction.y = 0.0
linear_velocity = direction * speed
bat_model.rotation.y = Vector3.FORWARD.signed_angle_to(direction, Vector3.UP) + PI
func take_damage():
if health <= 0:
return
bat_model.hurt()
health -= 1
+ hurt_sound.play()
if health == 0:
+ ko_sound.play()
set_physics_process(false)
gravity_scale = 1.0
var direction = player.global_position.direction_to(global_position)
var random_upward_force = Vector3.UP * randf() * 5.0
apply_central_impulse(direction.rotated(Vector3.UP, randf_range(-0.2, 0.2)) * 10.0 + random_upward_force)
timer.start()
died.emit()
func _on_timer_timeout():
queue_free()
The KillPlane node's body_entered
signal is connected to the Game node (not its area_entered
signal, which wouldn't detect the player)
Here's the game script, , with the new function for the kill plane highlighted:
extends Node3D
var player_score = 0
@onready var label := %Label
func increase_score():
player_score += 1
label.text = "Score: " + str(player_score)
func _on_mob_spawner_3d_mob_spawned(mob):
mob.died.connect(increase_score)
+func _on_kill_plane_body_entered(body):
+ get_tree().reload_current_scene.call_deferred()
In the mob scene, the Mob node's Collision
Here's the game script, , at the end of this section. I highlighted the lines to draw the mob's VFX.
extends Node3D
var player_score = 0
@onready var label := %Label
func increase_score():
player_score += 1
label.text = "Score: " + str(player_score)
+func do_poof(mob_position):
+ const SMOKE_PUFF = preload("res://mob/smoke_puff/smoke_puff.tscn")
+ var poof := SMOKE_PUFF.instantiate()
+ add_child(poof)
+ poof.global_position = mob_position
func _on_mob_spawner_3d_mob_spawned(mob):
- mob.died.connect(increase_score)
+ mob.died.connect(func():
+ increase_score()
+ do_poof(mob.global_position)
+ )
+ do_poof(mob.global_position)
func _on_kill_plane_body_entered(body):
get_tree().reload_current_scene.call_deferred()
And here's the updated mob script, :
extends RigidBody3D
signal died
var health = 3
var speed = randf_range(2.0, 4.0)
@onready var bat_model = %bat_model
@onready var timer = %Timer
@onready var player = get_node("/root/Game/Player")
@onready var hurt_sound = %HurtSound
@onready var ko_sound = %KOSound
func _physics_process(delta):
var direction = global_position.direction_to(player.global_position)
direction.y = 0.0
linear_velocity = direction * speed
bat_model.rotation.y = Vector3.FORWARD.signed_angle_to(direction, Vector3.UP) + PI
func take_damage():
if health <= 0:
return
bat_model.hurt()
health -= 1
hurt_sound.play()
if health == 0:
ko_sound.play()
set_physics_process(false)
gravity_scale = 1.0
var direction = player.global_position.direction_to(global_position)
var random_upward_force = Vector3.UP * randf() * 5.0
apply_central_impulse(direction.rotated(Vector3.UP, randf_range(-0.2, 0.2)) * 10.0 + random_upward_force)
timer.start()
- died.emit()
func _on_timer_timeout():
queue_free()
+ died.emit()
You can go a lot further with this game. After completing the tutorial, the challenges below will help you improve your skills and make the game your own.
Remember that programming is a skill and you will only improve if you copy, customize, and then create.
We prepared some challenges to help you get started, experimenting and making the project yours. They come with hints and a solution to compare against in the solutions version of the Godot project files you downloaded.
The challenges will take research and experimentation. Each of them comes with hints that help you one step of the way at a time.
These challenges are, well, challenging! You will need to research and help each other to beat them. If you can't beat them, no worries! The bulk of the learning and skill improvement happens when you work hard and try to implement mechanics and search for a solution. You can always come back to the challenges later when you have more experience.
In programming, there are often many ways to achieve the same result. When you're getting started, as long as your result looks similar to the challenge video example, you're all good. The challenge is in getting the result in the game, not writing the code in a specific way.
Writing code that is as simple as can be will come with experience.
Add a time limit to the game, this is the time the player needs to survive. Display the remaining time centered at the top of the screen.
If the player stays alive all the time, display a message centered on the screen to congratulate them.
The final result should look something like this:
Check out the get_tree().paused
property to pause the game when it ends.
timeout
signal to know when the time ran out and want to display the end text.
timeout
signal once.
get_tree().paused
property.
Add a health bar and make the player take damage when colliding with the bats.
The results should look something like this:
If you've watched the tutorial Your First 2D Game with Godot 4 before, the principle behind this challenge is similar to the player health in the 2D tutorial. However, some of the required code will be different because this is 3D and the mobs are nodes. RigidBody3D
Godot has built-in properties to pause the game and control what the player can do while the game is paused.
Pausing the game with get_tree().paused
To pause the game, you need to:
get_tree()
.paused
property of the scene tree to true
.get_tree().paused = true
To unpause the game, you set the scene tree's paused
property to false
.
get_tree().paused = false
By default, this pauses all nodes in your game, which will prevent the player from doing anything, including navigating a pause menu or unpausing the game.
To control which nodes pause and which continue running, you need to change the process mode property of the nodes you want to control.
Keeping nodes active while the game is paused
When you select a node, if you scroll to the bottom of the Inspector dock, you will see the property Process
By default, it's set to Inherit, which means the node will inherit the pause behavior from its parent node. By default, the root of the scene tree is paused when the game is paused and that's why all nodes in the game are paused.
You can set a node's Process
You can do this for the root node of a pause menu or a game over menu, for example, to keep it interactive while the game is paused.
Here's how to do it in code:
func _ready() -> void:
# This code keeps the node to which the script is attached running while the
# game is paused.
process_mode = Node.PROCESS_MODE_ALWAYS
Toggling pause with a key
You can toggle the game's pause state by checking if a key is pressed in the _input()
function.
func _input(event: InputEvent) -> void:
if event.is_action_pressed("toggle_pause"):
get_tree().paused = not get_tree().paused
In this code, we:
"toggle_pause"
is pressed. Doing this in the _input()
function allows you to catch input events before the user interface and other nodes in the scene.paused
property to the opposite of its current value. If the game is paused, the paused
property will be true
, and writing not
before it will set it to false
, and vice versa."toggle_pause"
in the project settings for this code to work. Go to ProjectIn GDScript, calling functions like get_tree()
is a very cheap operation. Calling an engine's built-in function like this is surprisingly much faster than calling a function you would write yourself in the same script.
The engine is optimized to handle these calls efficiently. So you don't need to worry about performance when calling built-in functions like get_tree()
multiple times in a script, especially not as a beginner.
When you gain experience and start working on performance-intensive code, you can start measuring the performance of different parts of your code.
Don't stop here. Step-by-step tutorials are fun but they only take you so far.
Try one of our proven study programs to become an independent Gamedev truly capable of realizing the games you’ve always wanted to make.
Get help from peers and pros on GDQuest's Discord server!
20,000 membersJoin ServerThere are multiple ways you can join our effort to create free and open source gamedev resources that are accessible to everyone!
Sponsor this library by learning gamedev with us onGDSchool
Learn MoreImprove and build on assets or suggest edits onGithub
Contributeshare this page and talk about GDQUest onRedditYoutubeTwitter…