Build 2D and 3D games with Godot engine using GDScript. Implement scenes, nodes, signals, physics, and export to Windows, Linux, macOS, Android, iOS, and Web. Use when building games, open-source game development, Godot, GDScript, game engine, scenes, nodes, signals, platformer, RPG, or indie game.
A comprehensive skill for building 2D and 3D games with the Godot Engine using GDScript.
Get a Godot game running in 5 minutes:
# Linux (Flatpak)
flatpak install flathub org.godotengine.Godot
# macOS (Homebrew)
brew install --cask godot
# Windows (Scoop)
scoop install godot
# Or download directly from:
# https://godotengine.org/download
# Create project directory
mkdir my-game && cd my-game
# Copy the starter template
cp -r /path/to/godot-game-builder/templates/starter-app/* .
# Open in Godot Editor
godot --path . --editor
Press F5 in the Godot Editor or run:
godot --path .
# Install export templates first (do this once)
# In Godot: Editor > Manage Export Templates > Download
# Export to Windows
godot --headless --export-release "Windows Desktop" build/game.exe
# Export to Linux
godot --headless --export-release "Linux" build/game.x86_64
# Export to Web
godot --headless --export-release "Web" build/index.html
Godot is a free, open-source 2D and 3D game engine released under the MIT license. It provides a comprehensive set of tools for game development including:
| Feature | Description |
|---|---|
| 2D Engine | Dedicated 2D rendering with pixel-perfect support |
| 3D Engine | Vulkan-based 3D renderer with PBR materials |
| Physics | Built-in 2D and 3D physics engines |
| Animation | AnimationPlayer, AnimationTree, skeletal animation |
| Audio | Spatial audio, bus system, effects |
| Networking | High-level multiplayer API, WebSocket support |
| GUI | Comprehensive UI toolkit with theming |
| Scripting | GDScript, C#, C++ (GDExtension) |
| Platform | Requirements |
|---|---|
| Windows | Windows 7+ (x86_64) |
| Linux | Ubuntu 18.04+ (x86_64, ARM) |
| macOS | macOS 10.12+ (Intel, Apple Silicon) |
| Android | Android 6.0+, requires Android SDK |
| iOS | Requires macOS + Xcode |
| Web | HTML5/WebAssembly (WebGL 2.0) |
| Consoles | Via third-party publishers |
Before building a Godot game, gather the following information:
## Game Concept Questionnaire
### Core Gameplay
1. What type of game? (Platformer, RPG, Puzzle, Action, etc.)
2. 2D or 3D graphics?
3. Single-player, multiplayer, or both?
4. What are the core mechanics?
### Visual Style
1. Art style? (Pixel art, vector, realistic, stylized)
2. Target resolution? (1920x1080, 1280x720, etc.)
3. Aspect ratio handling? (Fixed, expand, keep)
4. UI style requirements?
### Technical Requirements
1. Target platforms? (PC, Mobile, Web, Console)
2. Performance targets? (60fps, 30fps, mobile-friendly)
3. Save system needed?
4. Localization/multiple languages?
### Audio
1. Background music?
2. Sound effects?
3. Voice acting?
4. Dynamic audio?
### Scope
1. Estimated number of levels/scenes?
2. Character count?
3. Enemy types?
4. Unique mechanics count?
Game Type?
├── 2D Platformer
│ ├── Use CharacterBody2D for player
│ ├── Use TileMap for levels
│ └── Physics layers: player, enemies, world, pickups
│
├── 2D Top-Down
│ ├── Use CharacterBody2D with 8-directional movement
│ ├── Use Navigation2D for pathfinding
│ └── Consider Area2D for interactions
│
├── 3D First-Person
│ ├── Use CharacterBody3D for player
│ ├── Use RayCast3D for interactions
│ └── Consider SubViewport for weapon models
│
├── 3D Third-Person
│ ├── Use CharacterBody3D with SpringArm3D camera
│ ├── Use AnimationTree for character animations
│ └── Consider inverse kinematics for foot placement
│
├── Puzzle Game
│ ├── Use Control nodes for grid-based puzzles
│ ├── Consider Area2D for drag-and-drop
│ └── Use Tweens for smooth animations
│
└── RPG
├── Use ResourceScripts for data (items, characters)
├── Implement inventory system with signals
└── Use DialogueManager for conversations
my-game/
├── project.godot # Project configuration
├── export_presets.cfg # Export settings
├── .gitignore # Git ignore file
├── README.md # Project documentation
│
├── assets/ # Game assets
│ ├── sprites/ # 2D images and textures
│ │ ├── characters/ # Character sprites
│ │ ├── enemies/ # Enemy sprites
│ │ ├── tiles/ # Tileset images
│ │ └── ui/ # UI elements
│ ├── audio/ # Sound files
│ │ ├── music/ # Background music
│ │ └── sfx/ # Sound effects
│ ├── fonts/ # Font files
│ ├── models/ # 3D models (for 3D games)
│ └── shaders/ # Custom shaders
│
├── autoload/ # Global singletons
│ ├── game_manager.gd # Game state management
│ ├── audio_manager.gd # Audio playback
│ ├── scene_manager.gd # Scene transitions
│ └── signal_bus.gd # Global event bus
│
├── scenes/ # Game scenes
│ ├── main.tscn # Main scene
│ ├── player/ # Player scenes
│ ├── enemies/ # Enemy scenes
│ ├── levels/ # Level scenes
│ └── ui/ # UI scenes
│
├── scripts/ # GDScript files
│ ├── player/ # Player scripts
│ ├── enemies/ # Enemy scripts
│ ├── components/ # Reusable components
│ └── resources/ # Custom Resource scripts
│
├── ui/ # UI-specific scenes
│ ├── menus/ # Menu scenes
│ ├── hud/ # In-game HUD
│ └── dialogs/ # Dialog boxes
│
├── addons/ # Third-party plugins
│ └── gut/ # Testing framework
│
└── test/ # Unit tests
└── unit/ # Unit test scripts
; Essential project.godot settings
[application]
config/name="My Game"
config/description="A game built with Godot"
config/version="1.0.0"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.3", "Forward Plus")
config/icon="res://assets/icon.svg"
[autoload]
GameManager="*res://autoload/game_manager.gd"
AudioManager="*res://autoload/audio_manager.gd"
SceneManager="*res://autoload/scene_manager.gd"
SignalBus="*res://autoload/signal_bus.gd"
[display]
window/size/viewport_width=1920
window/size/viewport_height=1080
window/stretch/mode="canvas_items"
window/stretch/aspect="expand"
[input]
; Define input actions here
[layer_names]
2d_physics/layer_1="player"
2d_physics/layer_2="enemies"
2d_physics/layer_3="world"
2d_physics/layer_4="pickups"
[rendering]
renderer/rendering_method="forward_plus"
textures/canvas_textures/default_texture_filter=0
; Input actions in project.godot
[input]
move_left={
"deadzone": 0.5,
"events": [Key A, Key Left]
}
move_right={
"deadzone": 0.5,
"events": [Key D, Key Right]
}
move_up={
"deadzone": 0.5,
"events": [Key W, Key Up]
}
move_down={
"deadzone": 0.5,
"events": [Key S, Key Down]
}
jump={
"deadzone": 0.5,
"events": [Key Space]
}
action={
"deadzone": 0.5,
"events": [Key E]
}
pause={
"deadzone": 0.5,
"events": [Key Escape]
}
Main (Node2D)
├── Camera2D
├── World (Node2D)
│ ├── TileMap
│ ├── Platforms (Node2D)
│ └── Decorations (Node2D)
├── Entities (Node2D)
│ ├── Player (CharacterBody2D)
│ ├── Enemies (Node2D)
│ │ └── [Enemy instances]
│ └── Pickups (Node2D)
│ └── [Pickup instances]
├── CanvasLayer (layer=10)
│ ├── HUD (Control)
│ └── PauseMenu (Control)
└── AudioPlayers (Node)
├── MusicPlayer (AudioStreamPlayer)
└── SFXPlayer (AudioStreamPlayer)
Autoloads are globally accessible nodes. Register in project.godot:
## game_manager.gd - Global game state singleton
extends Node
# Signals
signal game_state_changed(old_state: GameState, new_state: GameState)
signal save_completed(success: bool)
# Enums
enum GameState { MENU, PLAYING, PAUSED, GAME_OVER }
# State
var current_state: GameState = GameState.MENU:
set(value):
var old := current_state
current_state = value
game_state_changed.emit(old, value)
var score: int = 0
var high_score: int = 0
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
load_game()
func start_game() -> void:
score = 0
current_state = GameState.PLAYING
func pause_game() -> void:
if current_state == GameState.PLAYING:
current_state = GameState.PAUSED
get_tree().paused = true
func resume_game() -> void:
if current_state == GameState.PAUSED:
current_state = GameState.PLAYING
get_tree().paused = false
func save_game() -> void:
var data := {
"high_score": high_score,
"settings": {}
}
var file := FileAccess.open("user://save.json", FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(data))
save_completed.emit(true)
else:
save_completed.emit(false)
func load_game() -> void:
if FileAccess.file_exists("user://save.json"):
var file := FileAccess.open("user://save.json", FileAccess.READ)
var json := JSON.new()
if json.parse(file.get_as_text()) == OK:
var data: Dictionary = json.data
high_score = data.get("high_score", 0)
## signal_bus.gd - Global event bus for decoupled communication
extends Node
# Game events
signal game_started
signal game_ended(score: int)
signal game_paused
signal game_resumed
# Player events
signal player_health_changed(current: int, max: int)
signal player_died
signal player_respawned(position: Vector2)
signal player_damaged(amount: int, source: Node)
# Score events
signal score_changed(new_score: int)
signal score_added(points: int)
# Level events
signal level_completed(level_id: int)
signal checkpoint_reached(checkpoint_id: String)
# Combat events
signal enemy_spawned(enemy: Node)
signal enemy_killed(enemy: Node)
# UI events
signal notification_requested(message: String, duration: float)
# Utility methods
func request_notification(msg: String, duration: float = 3.0) -> void:
notification_requested.emit(msg, duration)
## scene_manager.gd - Scene transitions with effects
extends Node
signal scene_change_started(from: String, to: String)
signal scene_change_completed(scene: String)
enum TransitionType { NONE, FADE, SLIDE_LEFT, SLIDE_RIGHT }
var _transition_layer: CanvasLayer
var _transition_rect: ColorRect
var _is_transitioning: bool = false
var _current_scene_path: String = ""
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
_setup_transition_layer()
_current_scene_path = get_tree().current_scene.scene_file_path
func _setup_transition_layer() -> void:
_transition_layer = CanvasLayer.new()
_transition_layer.layer = 100
add_child(_transition_layer)
_transition_rect = ColorRect.new()
_transition_rect.color = Color.BLACK
_transition_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
_transition_rect.modulate.a = 0.0
_transition_layer.add_child(_transition_rect)
func change_scene(path: String, transition: TransitionType = TransitionType.FADE) -> void:
if _is_transitioning:
return
_is_transitioning = true
scene_change_started.emit(_current_scene_path, path)
# Fade out
if transition == TransitionType.FADE:
var tween := create_tween()
tween.tween_property(_transition_rect, "modulate:a", 1.0, 0.3)
await tween.finished
# Change scene
get_tree().change_scene_to_file(path)
await get_tree().process_frame
_current_scene_path = path
# Fade in
if transition == TransitionType.FADE:
var tween := create_tween()
tween.tween_property(_transition_rect, "modulate:a", 0.0, 0.3)
await tween.finished
_is_transitioning = false
scene_change_completed.emit(path)
## player.gd - 2D Platformer player controller
class_name Player
extends CharacterBody2D
# Signals
signal health_changed(current: int, max: int)
signal died
# Exports
@export_group("Movement")
@export var speed: float = 300.0
@export var jump_velocity: float = -400.0
@export var acceleration: float = 2000.0
@export var friction: float = 1500.0
@export_group("Physics")
@export var coyote_time: float = 0.1
@export var jump_buffer_time: float = 0.1
@export_group("Health")
@export var max_health: int = 100
# Onready
@onready var sprite: Sprite2D = $Sprite2D
@onready var anim_player: AnimationPlayer = $AnimationPlayer
@onready var coyote_timer: Timer = $CoyoteTimer
@onready var jump_buffer_timer: Timer = $JumpBufferTimer
# State
var _current_health: int
var _gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")
var _was_on_floor: bool = false
var _jump_buffered: bool = false
enum State { IDLE, RUNNING, JUMPING, FALLING, DEAD }
var _state: State = State.IDLE
func _ready() -> void:
_current_health = max_health
coyote_timer.wait_time = coyote_time
jump_buffer_timer.wait_time = jump_buffer_time
func _physics_process(delta: float) -> void:
if _state == State.DEAD:
return
_apply_gravity(delta)
_handle_jump()
_handle_movement(delta)
_update_state()
_update_animation()
_was_on_floor = is_on_floor()
move_and_slide()
func _apply_gravity(delta: float) -> void:
if not is_on_floor():
velocity.y += _gravity * delta
func _handle_jump() -> void:
# Coyote time: allow jump shortly after leaving platform
if _was_on_floor and not is_on_floor():
coyote_timer.start()
# Buffer jump input
if Input.is_action_just_pressed("jump"):
if is_on_floor() or not coyote_timer.is_stopped():
_do_jump()
else:
jump_buffer_timer.start()
_jump_buffered = true
# Execute buffered jump when landing
if is_on_floor() and _jump_buffered and not jump_buffer_timer.is_stopped():
_do_jump()
_jump_buffered = false
func _do_jump() -> void:
velocity.y = jump_velocity
coyote_timer.stop()
func _handle_movement(delta: float) -> void:
var direction := Input.get_axis("move_left", "move_right")
if direction != 0:
velocity.x = move_toward(velocity.x, direction * speed, acceleration * delta)
sprite.flip_h = direction < 0
else:
velocity.x = move_toward(velocity.x, 0, friction * delta)
func _update_state() -> void:
if velocity.y < 0:
_state = State.JUMPING
elif velocity.y > 0 and not is_on_floor():
_state = State.FALLING
elif abs(velocity.x) > 10:
_state = State.RUNNING
else:
_state = State.IDLE
func _update_animation() -> void:
match _state:
State.IDLE:
anim_player.play("idle")
State.RUNNING:
anim_player.play("run")
State.JUMPING:
anim_player.play("jump")
State.FALLING:
anim_player.play("fall")
func take_damage(amount: int) -> void:
_current_health = max(_current_health - amount, 0)
health_changed.emit(_current_health, max_health)
SignalBus.player_health_changed.emit(_current_health, max_health)
if _current_health <= 0:
_die()
func _die() -> void:
_state = State.DEAD
died.emit()
SignalBus.player_died.emit()
## player_topdown.gd - Top-down 8-directional movement
class_name PlayerTopDown
extends CharacterBody2D
@export var speed: float = 200.0
@export var acceleration: float = 1500.0
@export var friction: float = 1200.0
@onready var sprite: Sprite2D = $Sprite2D
@onready var anim_tree: AnimationTree = $AnimationTree
var _last_direction: Vector2 = Vector2.DOWN
func _physics_process(delta: float) -> void:
var input_direction := Input.get_vector(
"move_left", "move_right", "move_up", "move_down"
)
if input_direction != Vector2.ZERO:
velocity = velocity.move_toward(input_direction * speed, acceleration * delta)
_last_direction = input_direction.normalized()
else:
velocity = velocity.move_toward(Vector2.ZERO, friction * delta)
_update_animation(input_direction)
move_and_slide()
func _update_animation(direction: Vector2) -> void:
if anim_tree == null:
return
var anim_state: AnimationNodeStateMachinePlayback = anim_tree.get("parameters/playback")
if direction != Vector2.ZERO:
anim_tree.set("parameters/Walk/blend_position", direction)
anim_tree.set("parameters/Idle/blend_position", direction)
anim_state.travel("Walk")
else:
anim_state.travel("Idle")
## enemy.gd - Basic enemy with patrol/chase/attack states
class_name Enemy
extends CharacterBody2D
signal died
@export_group("Movement")
@export var speed: float = 100.0
@export var patrol_points: Array[Vector2] = []
@export_group("Detection")
@export var detection_range: float = 200.0
@export var attack_range: float = 50.0
@export var lose_sight_range: float = 300.0
@export_group("Combat")
@export var damage: int = 10
@export var attack_cooldown: float = 1.0
@export var max_health: int = 50
@onready var raycast: RayCast2D = $RayCast2D
@onready var attack_timer: Timer = $AttackTimer
enum State { IDLE, PATROL, CHASE, ATTACK, DEAD }
var _state: State = State.IDLE
var _health: int
var _player: CharacterBody2D
var _patrol_index: int = 0
var _gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")
func _ready() -> void:
_health = max_health
attack_timer.wait_time = attack_cooldown
_find_player()
func _physics_process(delta: float) -> void:
if _state == State.DEAD:
return
velocity.y += _gravity * delta
match _state:
State.IDLE:
_idle_state()
State.PATROL:
_patrol_state(delta)
State.CHASE:
_chase_state(delta)
State.ATTACK:
_attack_state()
move_and_slide()
func _idle_state() -> void:
if _can_see_player():
_change_state(State.CHASE)
elif patrol_points.size() > 0:
_change_state(State.PATROL)
func _patrol_state(_delta: float) -> void:
if _can_see_player():
_change_state(State.CHASE)
return
var target := patrol_points[_patrol_index]
var direction := (target - global_position).normalized()
velocity.x = direction.x * speed
if global_position.distance_to(target) < 10:
_patrol_index = (_patrol_index + 1) % patrol_points.size()
func _chase_state(_delta: float) -> void:
if _player == null:
_change_state(State.IDLE)
return
var distance := global_position.distance_to(_player.global_position)
if distance > lose_sight_range:
_change_state(State.PATROL if patrol_points.size() > 0 else State.IDLE)
return
if distance < attack_range:
_change_state(State.ATTACK)
return
var direction := (_player.global_position - global_position).normalized()
velocity.x = direction.x * speed
func _attack_state() -> void:
velocity.x = 0
if _player == null or global_position.distance_to(_player.global_position) > attack_range:
_change_state(State.CHASE)
return
if attack_timer.is_stopped():
_perform_attack()
attack_timer.start()
func _perform_attack() -> void:
if _player and _player.has_method("take_damage"):
_player.take_damage(damage)
func _change_state(new_state: State) -> void:
_state = new_state
func _can_see_player() -> bool:
if _player == null:
return false
return global_position.distance_to(_player.global_position) < detection_range
func _find_player() -> void:
# Find player in group
var players := get_tree().get_nodes_in_group("player")
if players.size() > 0:
_player = players[0]
func take_damage(amount: int) -> void:
_health -= amount
SignalBus.enemy_damaged.emit(self, amount, null)
if _health <= 0:
_die()
func _die() -> void:
_state = State.DEAD
died.emit()
SignalBus.enemy_killed.emit(self, _player)
queue_free()
## health_component.gd - Reusable health system component
class_name HealthComponent
extends Node
signal health_changed(current: int, maximum: int)
signal died
signal damaged(amount: int)
signal healed(amount: int)
@export var max_health: int = 100
@export var invincibility_time: float = 0.0
var current_health: int:
get:
return _current_health
var is_alive: bool:
get:
return _current_health > 0
var _current_health: int
var _is_invincible: bool = false
func _ready() -> void:
_current_health = max_health
func take_damage(amount: int) -> void:
if _is_invincible or not is_alive:
return
_current_health = max(_current_health - amount, 0)
damaged.emit(amount)
health_changed.emit(_current_health, max_health)
if _current_health <= 0:
died.emit()
elif invincibility_time > 0:
_start_invincibility()
func heal(amount: int) -> void:
if not is_alive:
return
var actual_heal := min(amount, max_health - _current_health)
_current_health += actual_heal
healed.emit(actual_heal)
health_changed.emit(_current_health, max_health)
func reset() -> void:
_current_health = max_health
_is_invincible = false
health_changed.emit(_current_health, max_health)
func _start_invincibility() -> void:
_is_invincible = true
await get_tree().create_timer(invincibility_time).timeout
_is_invincible = false
func get_health_percentage() -> float:
return float(_current_health) / float(max_health)
## hitbox.gd - Damage dealing area
class_name Hitbox
extends Area2D
@export var damage: int = 10
@export var knockback_force: float = 200.0
func _ready() -> void:
area_entered.connect(_on_area_entered)
func _on_area_entered(area: Area2D) -> void:
if area is Hurtbox:
var hurtbox := area as Hurtbox
hurtbox.take_hit(damage, knockback_force, global_position)
## hurtbox.gd - Damage receiving area
class_name Hurtbox
extends Area2D
signal hit_received(damage: int, knockback: float, source_position: Vector2)
@export var health_component: HealthComponent
func take_hit(damage: int, knockback: float, source_pos: Vector2) -> void:
hit_received.emit(damage, knockback, source_pos)
if health_component:
health_component.take_damage(damage)
## item_resource.gd - Custom Resource for items
class_name ItemResource
extends Resource
@export var id: String = ""
@export var name: String = ""
@export var description: String = ""
@export var icon: Texture2D
@export var max_stack: int = 99
@export var item_type: ItemType = ItemType.MISC
enum ItemType { WEAPON, ARMOR, CONSUMABLE, KEY_ITEM, MISC }
## inventory.gd - Inventory system
class_name Inventory
extends Node
signal item_added(item: ItemResource, quantity: int)
signal item_removed(item: ItemResource, quantity: int)
signal inventory_changed
@export var max_slots: int = 20
# Structure: { "item_id": { "item": ItemResource, "quantity": int } }
var _items: Dictionary = {}
func add_item(item: ItemResource, quantity: int = 1) -> int:
"""Add item to inventory. Returns quantity that couldn't be added."""
var item_id := item.id
var remaining := quantity
if _items.has(item_id):
var current: Dictionary = _items[item_id]
var space := item.max_stack - current.quantity
var to_add := min(remaining, space)
current.quantity += to_add
remaining -= to_add
elif _items.size() < max_slots:
var to_add := min(remaining, item.max_stack)
_items[item_id] = { "item": item, "quantity": to_add }
remaining -= to_add
if remaining < quantity:
item_added.emit(item, quantity - remaining)
inventory_changed.emit()
return remaining
func remove_item(item_id: String, quantity: int = 1) -> bool:
"""Remove item from inventory. Returns true if successful."""
if not _items.has(item_id):
return false
var current: Dictionary = _items[item_id]
if current.quantity < quantity:
return false
current.quantity -= quantity
var item: ItemResource = current.item
if current.quantity <= 0:
_items.erase(item_id)
item_removed.emit(item, quantity)
inventory_changed.emit()
return true
func has_item(item_id: String, quantity: int = 1) -> bool:
if not _items.has(item_id):
return false
return _items[item_id].quantity >= quantity
func get_item_count(item_id: String) -> int:
if not _items.has(item_id):
return 0
return _items[item_id].quantity
func get_all_items() -> Array[Dictionary]:
return _items.values()
func clear() -> void:
_items.clear()
inventory_changed.emit()
## dialog_resource.gd - Dialog data resource
class_name DialogResource
extends Resource
@export var dialog_id: String = ""
@export var lines: Array[DialogLine] = []
class DialogLine:
var speaker: String
var text: String
var portrait: Texture2D
var choices: Array[DialogChoice]
class DialogChoice:
var text: String
var next_dialog_id: String
var condition: String
## dialog_manager.gd - Dialog playback system
class_name DialogManager
extends CanvasLayer
signal dialog_started(dialog_id: String)
signal dialog_ended(dialog_id: String)
signal choice_selected(choice_index: int)
@onready var dialog_box: PanelContainer = $DialogBox
@onready var speaker_label: Label = $DialogBox/VBox/Speaker
@onready var text_label: RichTextLabel = $DialogBox/VBox/Text
@onready var portrait: TextureRect = $DialogBox/Portrait
@onready var choices_container: VBoxContainer = $DialogBox/Choices
var _current_dialog: DialogResource
var _current_line_index: int = 0
var _is_active: bool = false
var _text_speed: float = 0.03
func start_dialog(dialog: DialogResource) -> void:
_current_dialog = dialog
_current_line_index = 0
_is_active = true
dialog_box.visible = true
get_tree().paused = true
dialog_started.emit(dialog.dialog_id)
_show_current_line()
func _show_current_line() -> void:
if _current_line_index >= _current_dialog.lines.size():
_end_dialog()
return
var line: DialogResource.DialogLine = _current_dialog.lines[_current_line_index]
speaker_label.text = line.speaker
portrait.texture = line.portrait
# Typewriter effect
text_label.text = ""
for character in line.text:
text_label.text += character
await get_tree().create_timer(_text_speed).timeout
if line.choices.size() > 0:
_show_choices(line.choices)
func _show_choices(choices: Array) -> void:
for child in choices_container.get_children():
child.queue_free()
for i in range(choices.size()):
var choice: DialogResource.DialogChoice = choices[i]
var button := Button.new()
button.text = choice.text
button.pressed.connect(_on_choice_pressed.bind(i))
choices_container.add_child(button)
func _on_choice_pressed(index: int) -> void:
choice_selected.emit(index)
advance_dialog()
func advance_dialog() -> void:
if not _is_active:
return
_current_line_index += 1
choices_container.get_children().all(func(c): c.queue_free())
_show_current_line()
func _end_dialog() -> void:
_is_active = false
dialog_box.visible = false
get_tree().paused = false
dialog_ended.emit(_current_dialog.dialog_id)
func _unhandled_input(event: InputEvent) -> void:
if _is_active and event.is_action_pressed("action"):
advance_dialog()
addons/gut/## test/unit/test_player.gd
extends GutTest
var player: Player
var player_scene := preload("res://scenes/player.tscn")
func before_each() -> void:
player = player_scene.instantiate()
add_child(player)
func after_each() -> void:
player.queue_free()
func test_player_starts_with_full_health() -> void:
assert_eq(player.get_health(), player.max_health)
func test_player_takes_damage() -> void:
var initial_health := player.get_health()
player.take_damage(10)
assert_eq(player.get_health(), initial_health - 10)
func test_player_dies_at_zero_health() -> void:
var died_signal := watch_signals(player)
player.take_damage(player.max_health)
assert_signal_emitted(player, "died")
func test_player_cannot_have_negative_health() -> void:
player.take_damage(player.max_health + 100)
assert_eq(player.get_health(), 0)
func test_player_movement() -> void:
# Simulate input
Input.action_press("move_right")
await get_tree().physics_frame
await get_tree().physics_frame
Input.action_release("move_right")
assert_gt(player.velocity.x, 0, "Player should move right")
# Run all tests via command line
godot --headless -s addons/gut/gut_cmdln.gd
# Run specific test file
godot --headless -s addons/gut/gut_cmdln.gd -gtest=res://test/unit/test_player.gd
# Run with verbose output
godot --headless -s addons/gut/gut_cmdln.gd -glog=3
## test/unit/test_inventory_gdunit.gd
class_name TestInventory
extends GdUnitTestSuite
var inventory: Inventory
func before_test() -> void:
inventory = Inventory.new()
add_child(inventory)
func after_test() -> void:
inventory.queue_free()
func test_add_item() -> void:
var item := ItemResource.new()
item.id = "potion"
item.max_stack = 10
var remaining := inventory.add_item(item, 5)
assert_int(remaining).is_equal(0)
assert_int(inventory.get_item_count("potion")).is_equal(5)
func test_remove_nonexistent_item_fails() -> void:
var result := inventory.remove_item("fake_item")
assert_bool(result).is_false()
; export_presets.cfg - Windows Desktop example
[preset.0]
name="Windows Desktop"
platform="Windows Desktop"
runnable=true
export_filter="all_resources"
export_path="build/windows/game.exe"
[preset.0.options]
binary_format/embed_pck=true
texture_format/bptc=true
texture_format/s3tc=true
application/icon="res://assets/icon.ico"
# Export to Windows
godot --headless --export-release "Windows Desktop" build/windows/game.exe
# Export to Linux
godot --headless --export-release "Linux" build/linux/game.x86_64
# Export to macOS
godot --headless --export-release "macOS" build/macos/game.dmg
# Export to Web
godot --headless --export-release "Web" build/web/index.html
# Export to Android APK
godot --headless --export-release "Android" build/android/game.apk
# Export debug build
godot --headless --export-debug "Windows Desktop" build/windows/game_debug.exe
signtool from Windows SDKchmod +x game.x86_64ANDROID_HOME environment variableCross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
# .github/workflows/build.yml