Lose Conditions
For this tutorial, the game is lost when the level times out, or when the player falls off the ground into an Area3D "sensor".
When time runs out, the player will lose the game.
- Create a timer scene:
- From the 3D View, create a new scene with "CMD+N"
- Click on the "+ Other Node" in the SceneTree
- Search for and add a "Control" node
- Rename the node to "Level_Timer"
- Save the scene with "CMD+S" and save within the UI folder (perhaps in a timer folder)
- Add a label to display the countdown time:
- Click on the "+" button to at the top of the SceneTree panel to search for and add a "Label" node
- Rename the label to "CountdownLabel"
- In the Inspector:
- Add placeholder text for the label — such as 00:00
- Under "Theme Overrides", change the font, font size, and color as needed
- From the 2D View, adjust the location of the label as needed
-
- Add the timer:
- Make sure the root "Level_Timer" node is active
- Click on the "+" button to at the top of the SceneTree panel to search for and add a "Timer" node
- Rename the label to "CountdownTimer"
- In the Inspector, check "One Shot", so that the timer only runs 1 time
- Create a script for the timer:
- Make sure the root "Level_Timer" node is active
- Click on the "+ Script" button at the top of the SceneTree panel to create an empty script
- Save the script
- Begin the timer script:
- In the timer script, add global variables:
# extend gives access to any properties, methods, or functions that are available to the class or inherited by the Control class
extends Control
# A public variable, available in the inspector, that sets the amount of time to complete the level
# 10 is just a placeholder to represent 10 seconds — this can be overridden in each level as needed
@export var amount_of_time : int = 10
# Sets a variable to represent the label, leaving an empty value for a just moment
# $ + nodepath lets you find a node in the SceneTree by the name/path
@onready var label : Node =
# Sets a variable to represent the timer node, leaving an empty value for a just moment
@onready var timer : Timer =
- Add the CountdownLabel as the value for the label variable:
- Drag/drop the countdown label's node from the SceneTree to the end of the label variable in the script
- Add the CountdownTimer as the value for the timer variable:
- Drag/drop the countdown timer's node from the SceneTree to the end of the label variable in the script
- After the global variables, create a _ready() function to begin the timer:
# Called when the node and its children are ready — they've entered the scene tree
func _ready() :
# wait_time is a property of the Timer class that is the initial time where the timer starts counting down
# Set the wait_time to use the amount_of_time variable's value
timer.wait_time = amount_of_time
# start() is a method of the Timer class that begins the timer
timer.start()
- Add a function to find out how much time is left in minutes and seconds:
# A custom function to find out how much time is left in minutes and seconds
func time_left_in_level() :
# This creates a variable to represent the time remaining for the timer
# time_left is a default property of the Timer class - it is how much time the timer has left in seconds
var time_remaining = timer.time_left
# This creates a variable to represent the number of minutes left
# floor() is a default method that rounds downward to the nearest whole number
# Dividing a number of seconds (the time_remaining value) by 60 and then rounding that result gets the number of minutes
var minute = floor(time_remaining / 60)
# This creates a variable to represent the seconds left in the current minute
# In this context, % is being used to get the remainder of time_remaining being divided by 60
# int() converts the time_remaining value to an integer
var second = int(time_remaining) % 60
# This returns the time left in minutes and seconds whenever the function runs
return [minute, second]
- Create a _process(delta) function to replace the text in the label with the time remaining:
# _process(delta) is a default function that runs every frame
# using the delta parameter adds consitency for time across different systems/devices
# Timer nodes don't need to factor in delta, so an _ is added to the front of the parameter to ignore it
func _process(_delta) :
# In a string, % is used to create a placeholder to set where something will be replaced
# 02 is setting a number of places and d sets the number as a decimal, giving the string 00:00 as a format
# After a string with % placeholders, whatever follows the % will replace the placeholders in the string
# text is a default property of the Label class
# The label's text gets changed to the minute:second values returned in the time_left_in_level() function
label.text = "%02d:%02d" % time_left_in_level()
- Save the scene with "CMD+S"
- Add the timer scene as an instance to each level:
- In each level, at the top of the SceneTree, click on the link icon
- Search for and choose the timer.tscn
- Preview the game from the Game scene with "CMD+R" to confirm the timer works as expected
- In the Global Signals script, add a new signal for running out of time:
# A custom signal for running out of time
signal portal_entered
signal out_of_time
- Connect the default timeout signal to a timeout function:
- Select the CountdownTimer node in the SceneTree
- In the sidebar's Node tab, under "Signals", double-click on "timeout()"
- From the signal connection dialog, choose the root node called "Level Timer" then click on "Connect"
- In the Timer Script's new timeout function, emit the out of time signal:
label.text = "%02d:%02d" % time_left_in_level()
# A function created from the connected timer's time out signal
func _on_countdown_timer_timeout():
# Use GlobalSignals to emit the signal for running out of time
GlobalSignals.emit_signal("out_of_time")
- In the main "Game" script' _ready() function, connect the out_of_time signal to a new time out function:
GlobalSignals.portal_entered.connect(set_next_level)
# Connects an "out_of_time" signal to a custom time_out function that will be added later
GlobalSignals.out_of_time.connect(time_out)
- At the end of the script, add that time_out function, with pass as a temporary placeholder:
player_instance.global_position = player_spawn_loc.global_position
# A custom function to set the time out message and then run a game over function
func time_out():
# This is a temporary placeholder to stop any warnings
pass
- Save with CMD+S
Area3D nodes can be used as "sensors" that detect when a physicsbody3D enters/exits. This is great
- Create a new signal for when the player dies in the GlobalSignals script:
# A custom signal for when the player dies
signal out_of_time
signal player_died
- Save changes to the script with "CMD+S"
- Create a new scene to use as a sensor that recognizes when the player falls off of the terrain/ground:
- With the 3D View active, press "CMD+N" to make a new scene
- Click on "+ Other Node" to search for and add an "Area3D" node
- Rename the node to FallSensor
- With the FallSensor node selected, click on the + at the top of the SceneTree to add a child node then search for and add a "CollisionShape3D" node
- In the Inspector, set the collision shape to a new "BoxShape3D"
- Click on the BoxShape3D in the Inspector to expand the size settings
- Set the size to be much larger than the ground in any scene — perhaps X: 100, Y: 1, and Z:100
- Under Collision, set the Layer/Mask:
- Layer = 2 (or whatever layer number is for the world or etc.)
- Mask = 1 (or whatever layer number is for the player)
- Save the Scene (CMD+S) and save it in within the assets folder — perhaps in a new FallSensor folder
- Make a script that recognizes when the player exits the Area3D:
- With the root node "FallSensor" selected, click on the SceneTree's new script button
- Create an empty script, saved in the same location as the FallSensor scene
- Connect the Area3D's default exit signal to the script:
- With the root node "FallSensor" selected in the SceneTree, change the right sidebar to the "Node" tab
- Under Signals → Area3D, double-click on "body_exited(body: Node3D)"
- In the signal connection window, make sure the root node "Fallsensor" is connected, then click on connect
- In the new _on_body_exited() function, emit the player_death signal:
# This gives access to the methods and properties available to the Area3D class
extends Area3D
# This function was created by connecting the _on_body_exited signal from Area3D
# The body parameter will be unused, so an _ is added in front to disable it
# This function will run whenever a Physicsbody3D (like the player) exits through the Area3D
# In this case exit is preferrable to enter, to prevent the sensor being triggered unintentionally by the player on scene changes
func _on_body_exited(_body):
# The global signals script is accessed in order to emit the player_death signal
GlobalSignals.emit_signal("player_death")
- Save the script with "CMD+S"
- In the main "Game" script's _ready() function, connect the player_death signal to a player_died function:
GlobalSignals.out_of_time.connect(time_out)
# Connects a "player_death" signal to a custom player_died function that will be added later
GlobalSignals.player_death.connect(player_died)
- At the end of the script, add that new player_died function, with pass as a temporary placeholder:
pass
# A custom function to set the player died message and then run a game over function
func player_died():
# This is just a temporary placeholder to prevent warnings
pass
- Save the script with CMD+S
- Add the FallSensor as an instance to any levels where the player can fall off the ground/terrain:
- Open the level scene file
- In the SceneTree, click on the link icon to search for and add the FallSensor scene
- From the 3D View, adjust the position of FallSensor node to be somewhere below the ground
- Adjust the size and location as needed
- "CMD+S" to save the scene
- Repeat for other levels
Win Conditions
For this tutorial, the game is won when the player reaches a goal, using collision.
- Create a new signal for when the player reaches the goal in the GlobalSignals script:
# A custom signal for reaching the goal and winning
signal player_death
signal goal_reached
- Save changes to the script with "CMD+S"
- Connect the signal in the main Game script, at the end of the other signals in the _ready() function:
GlobalSignals.out_of_time.connect(time_out)
# Connects a "goal_reached" signal to a custom function for winning the game, that will be added later
GlobalSignals.goal_reached.connect(game_won)
- At the end of the script, create the game_won function, with pass as a temporary placeholder for now:
pass
# A custom function to set the player won message and then run a game over function
func game_won():
# This is just a temporary placeholder to stop warnings
pass
- Create a Goal object scene:
- From the 3D View, press "CMD+N" to create a new scene
- Click on "+ Other Node" to search for and add a "StaticBody3D" node
- Rename the node to Goal
- Save the scene with "CMD+S" and choose/create a relevent location to save — perhaps Assets/Goal
- Follow the directions under "Adding Obstacles as Instances" in the "Levels" tab to add either a MeshInstance3D or a Blender model
- With the "Goal" node selected, add a CollisionShape3D, using the steps under "Adding Collision" in the Levels tab
- After the collision shape and size are set, make sure to set the collision Layer/Mask in the Inspector (with the root "Goal" node selected):
- Layer = 2 (or whatever layer number is for the world or goal or etc.)
- Mask = 1 (or whatever layer number is for the player)
- Switch the right sidebar to the "Node" tab, and go to "Groups"
- With the root "Goal" node still selected, click in the empty field to create/add a group called "goal"
- Save the scene with "CMD+S"
- Add the Goal scene as an instance in the final level:
- With the final level scene open, click on the main parent/root node (Level #...)
- At the top of the SceneTree panel click on the link icon to search for/add the "Goal" scene as an instance
- Adjust the scale/location/rotation of the goal as needed using the 3D View controls, or the Inspector's transform settings for the instanced "Goal" node
- Adjust the Player's script to recognize collisions with the goal:
- Open the Player script
- Add to the end of the _physics_process(delta) function:
set_jump_rotation()
# A "for loop" iterates/repeats through the code for each item in a list/array/dictionary
# get_slide_collision_count() returns the number of collisions/changes in direction in the last call from the move_and_slide() method of CharacterBody3D
# i is a temporary variable to represent each item in get_slide_collision_count as the loop executes
for i in get_slide_collision_count():
# This sets a collision variable called collision to represent the collision data from the temporary variable, using the get_slide_collision method with a type of KinematicCollision3D
var collision : KinematicCollision3D = get_slide_collision(i)
# get_collider() is a default method that finds the colliding body from an index
# This looks to see if there is a collision with a colliding body (it is not null), and if the collision is the goal group
if collision.get_collider().is_in_group("goal"):
# Access the GlobalSignals script to emit the goal_reached signal
GlobalSignals.emit_signal("goal_reached")
# otherwise ignore this
else:
pass
func set_jump_rotation():
Game Over
This uses the previously connected signals for reaching the goal, timing out, and falling off the terrain to set different win/lose messages.
- At the top of the script, with the other @onready variables, add a variable to represent the game over message:
@onready var player_instance : Node = $Player
# A variable that accesses the SceneTree to set the game over message to use the first (only) item in the menu-end group
@onready var game_over_message : Label = get_tree().get_first_node_in_group("menu-end")
var current_level : Node
- In the _ready() function, turn visbility of the game over message off at the game's start, after the signal connections and before the game_pause_toggle():
GlobalSignals.goal_reached.connect(game_won)
# At the start of the game, the menu shouldn't show any game over message
# .visible controls visibility, and setting the visibility to false hides the label
game_over_message.visible = false
game_paused_toggle()
- Adjust the time out function by replacing pass:
# A custom function to set the out of time message and then run a game over function
func out_of_time():
# This sets a game over message variable to use the string — choose your own message
# .text accesses the text
game_over_message.text = "Sorry, you ran out of time :("
# Run a custom game over function — it will be created a little later
game_over()
- Adjust the player died function by replacing pass:
# A custom function to set the player died message and then run a game over function
func player_died():
# This sets a game over message variable to use the string — choose your own message
# .text accesses the text
game_over_message.text = "Sorry, you died :("
# Run the custom game over function — it is coming soon
game_over()
- Adjust the player won function by replacing pass:
# A custom function to set the player won message and then run a game over function
func game_won():
# This sets a game over message variable to use the string — choose your own message
# .text accesses the text
game_over_message.text = "Amazing, you won!!!"
# Run the custom game over function — it will be created next
game_over()
After the game over messages are set, those functions trigger the game over function. This turns menu visibility back on, controls which parts of the menu display upon the end of the game, and removes the last level to reset the game.
- At the end of the script, create the game over function:
game_over()
# A custom function to run when the game ends
func game_over():
# Like the level changing function, this needs to use call_deferred to make sure processing is finished
call_deferred("_deferred_game_over")
- Create the deferred game over function:
call_deferred("_deferred_game_over")
# The deferred game over function
func _deferred_game_over():
# A "for" loop that sets a temporary variable called "ui" for items in the menu-start group, accessed from the SceneTree
for ui in get_tree().get_nodes_in_group("menu-start"):
# Turns the visibility off for any nodes in the group
ui.visible = false
# Use the game over message visiblity to true
game_over_message.visible = true
# Use the custom menu visibility function to toggle the menu back to visible
menu_visibility_toggle()
# Use the custom function to delete the last level played
delete_old_level()
- Save the game with CMD+S
- Preview with CMD+R and test the game:
- Does the time running out open the menu with the time out message?
- Does falling off the terrain open the menu with the player died message?
- Does reaching the goal open the menu with the player won message?
- Does the play button from the game over screen start the game over from the beginning?
- If there are any issues, use the standard troubleshooting steps — make sure names/spelling are correct/consistent, make sure indenting is correct, use print() in strategic places...
The process of creating/exporting a game app is called building. There are some settings that should be set before building the playable game app.
- Once the template is installed, go back to adjust the Export Settings:
- Select the platform on the left
- Next to "Export Path", click on the folder icon to choose and/or create a directory for a build location
- Next to "Bundle Identifier", give a name (if this was going to be sold in an app store, this would need to be unique — in this case a placeholder is fine, ie. abrhs2024.game)
- With macOS preset selected, choose "Export Project" NOT the other export options
- For the first build and other test builds, leave "Debug" on — turn off for a final build/release — then press save to actually export the game
- Testing locally on the same mac:
- To run on macOS, double-click on the DMG to open
- Drag and drop the app and debug log into the parent directory
- Double-click on the app to play it — on your own account it should run without issue
- If there are issues, run the game from the debug file (named with .command at the end of the name, ie. Game_Name.command)
- Look at the log that opens with the app to see the error messages from the game, and use that as a starting point for making changes back in Godot
- Running on other macs:
- Airdrop or download the DMG to the new mac
- Double-click on the DMG to open and drag/drop the app somewhere easy to access on the computer
- Opening the first time could fail and bring up a warning about an "unknown developer", since we are not participating in the "Apple Developer Program"
- If opening fails, close the warning dialog and then right-click on the app and choose 'Open' fromt he context menu
- When a new warning dialog opens, click on the "Open Anyways" option — after this the app should always open normally on this computer
If you've exported the game for other platforms, the general process is the same though the actual files generated might vary. If there are multiple files, just make sure they are always stored/kept together. Zip the build directory when sharing to another computer. Always extract the files to the same location. If certain files get separated, the game might not run.