
Godot Best Practices
- 1.9k installs
- 133 repo stars
- Updated February 24, 2026
- jwynia/agent-skills
godot-best-practices is an agent skill that Guide AI agents through Godot 4.x GDScript coding best practices including scene organization, signals, resources, state.
About
Guide AI agents in writing high quality GDScript code for Godot 4 x This skill provides coding standards architecture patterns and templates for game development Use this skill when Generating new GDScript code Creating or organizing Godot scenes Designing game architecture and node hierarchies Implementing state machines object pools or save systems Answering questions about GDScript patterns or Godot conventions Reviewing GDScript code for quality issues Do NOT use this skill when Working with C in Godot use C patterns Working with Godot 3 x syntax differs significantly Using GDExtension C different paradigm Working with Godot s visual scripting Follow GDScript naming standards consistently gdscript Classes PascalCase class_name PlayerController extends CharacterBody2D The godot best practices agent skill provides documented workflows prerequisites triggers and safety guidance from its SKILL md source Agents load it when user requests match the description and follow step by step instructions without inventing capabilities It integrates with standard agent tooling for the tasks inputs outputs and failure modes described in the repository documentation
- description: "Guide AI agents through Godot 4.x GDScript coding best practices including scene organization, signals, re
- compatibility: Requires Godot 4.x project. GDScript only (not C#).
- Guide AI agents in writing high-quality GDScript code for Godot 4.x. This skill provides coding standards, architecture
- Follow godot-best-practices SKILL.md steps and documented constraints.
- Follow godot-best-practices SKILL.md steps and documented constraints.
Godot Best Practices by the numbers
- 1,950 all-time installs (skills.sh)
- +16 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #638 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
godot-best-practices capabilities & compatibility
- Capabilities
- description: "guide ai agents through godot 4.x · compatibility: requires godot 4.x project. gdscr · guide ai agents in writing high quality gdscript · follow godot best practices skill.md steps and d
- Use cases
- orchestration
What godot-best-practices says it does
description: "Guide AI agents through Godot 4.x GDScript coding best practices including scene organization, signals, resources, state machines, and performance optimization. This skill should be used
compatibility: Requires Godot 4.x project. GDScript only (not C#).
Guide AI agents in writing high-quality GDScript code for Godot 4.x. This skill provides coding standards, architecture patterns, and templates for game development.
npx skills add https://github.com/jwynia/agent-skills --skill godot-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.9k |
|---|---|
| repo stars | ★ 133 |
| Security audit | 3 / 3 scanners passed |
| Last updated | February 24, 2026 |
| Repository | jwynia/agent-skills ↗ |
When should an agent use godot-best-practices and what problem does it solve?
Guide AI agents through Godot 4.x GDScript coding best practices including scene organization, signals, resources, state machines, and performance optimization. This skill should be used when generati
Who is it for?
Developers invoking godot-best-practices as documented in the skill source.
Skip if: Skip when requirements fall outside godot-best-practices documented scope.
When should I use this skill?
Guide AI agents through Godot 4.x GDScript coding best practices including scene organization, signals, resources, state machines, and performance optimization. This skill should be used when generati
What you get
Outputs aligned with the godot-best-practices SKILL.md workflow and stated deliverables.
- GDScript autoload manager file
- singleton service template
Files
Godot 4.x GDScript Best Practices
Guide AI agents in writing high-quality GDScript code for Godot 4.x. This skill provides coding standards, architecture patterns, and templates for game development.
When to Use This Skill
Use this skill when:
- Generating new GDScript code
- Creating or organizing Godot scenes
- Designing game architecture and node hierarchies
- Implementing state machines, object pools, or save systems
- Answering questions about GDScript patterns or Godot conventions
- Reviewing GDScript code for quality issues
Do NOT use this skill when:
- Working with C# in Godot (use C# patterns)
- Working with Godot 3.x (syntax differs significantly)
- Using GDExtension/C++ (different paradigm)
- Working with Godot's visual scripting
Core Principles
1. Naming Conventions
Follow GDScript naming standards consistently:
# Classes: PascalCase
class_name PlayerController
extends CharacterBody2D
# Signals: past_tense_snake_case (describe what happened)
signal health_changed(new_health: int)
signal player_died
signal item_collected(item: Item)
# Constants: SCREAMING_SNAKE_CASE
const MAX_SPEED: float = 200.0
const JUMP_FORCE: int = -400
# Variables and functions: snake_case
var current_health: int = 100
var _private_variable: float = 0.0 # Leading underscore for private
func calculate_damage(base: int, multiplier: float) -> int:
return int(base * multiplier)
func _private_helper() -> void: # Leading underscore for private
pass2. Type Hints (Static Typing)
Use explicit type hints everywhere for autocomplete and error detection:
# Variable declarations
var speed: float = 100.0
var player: CharacterBody2D
var items: Array[Item] = []
var stats: Dictionary = {}
# Function signatures with return types
func get_damage() -> int:
return _base_damage * _multiplier
func find_nearest_enemy(position: Vector2) -> Enemy:
# Implementation
return null
# Typed signals (Godot 4.x)
signal score_updated(new_score: int, old_score: int)
signal target_acquired(target: Node2D, distance: float)
# Node references with types
@onready var sprite: Sprite2D = $Sprite2D
@onready var collision: CollisionShape2D = $CollisionShape2D
@onready var animation_player: AnimationPlayer = %AnimationPlayer3. Node References
Use modern patterns for stable, refactor-friendly references:
# PREFER: @onready with type hints
@onready var health_bar: ProgressBar = $UI/HealthBar
@onready var weapon: Weapon = $WeaponMount/Weapon
# PREFER: Unique names with % for critical nodes
@onready var player: Player = %Player
@onready var game_manager: GameManager = %GameManager
# AVOID: get_node() in _ready()
func _ready() -> void:
# Don't do this
var sprite = get_node("Sprite2D")
# AVOID: Deep fragile paths
@onready var thing = $Parent/Child/GrandChild/GreatGrandChild # Fragile4. Signal-Driven Architecture
Use signals for decoupled communication. Follow "signal up, call down":
# Child node emits signals (doesn't know about parent)
class_name HealthComponent
extends Node
signal health_changed(current: int, maximum: int)
signal died
var _health: int = 100
var _max_health: int = 100
func take_damage(amount: int) -> void:
_health = max(0, _health - amount)
health_changed.emit(_health, _max_health)
if _health <= 0:
died.emit()# Parent connects to child signals (knows about children)
class_name Player
extends CharacterBody2D
@onready var health: HealthComponent = $HealthComponent
@onready var sprite: Sprite2D = $Sprite2D
func _ready() -> void:
health.health_changed.connect(_on_health_changed)
health.died.connect(_on_died)
func _on_health_changed(current: int, maximum: int) -> void:
# Update UI, play effects, etc.
pass
func _on_died() -> void:
sprite.modulate = Color.RED
queue_free()5. Resource Loading
Choose the right loading strategy:
# preload(): Compile-time loading for critical/small assets
const BULLET_SCENE: PackedScene = preload("res://scenes/bullet.tscn")
const PLAYER_SPRITE: Texture2D = preload("res://sprites/player.png")
const DAMAGE_SOUND: AudioStream = preload("res://audio/damage.wav")
# load(): Runtime loading for optional/large assets
func load_level(level_name: String) -> void:
var path := "res://levels/%s.tscn" % level_name
var level_scene: PackedScene = load(path)
var level := level_scene.instantiate()
add_child(level)
# ResourceLoader for async loading (prevents stuttering)
func _load_level_async(path: String) -> void:
ResourceLoader.load_threaded_request(path)
# Check with: ResourceLoader.load_threaded_get_status(path)
# Get with: ResourceLoader.load_threaded_get(path)Quick Reference
| Category | Prefer | Avoid |
|---|---|---|
| Node references | @onready var x: Type = $Path | get_node() in _ready() |
| Unique nodes | %UniqueName | Deep paths $A/B/C/D |
| Resource loading | preload() for small/critical | load() everywhere |
| Signals | Typed: signal x(val: int) | String: emit_signal("x") |
| Type safety | Explicit type hints | Untyped variables |
| Constants | const or @export | Magic numbers/strings |
| Null checks | is_instance_valid(node) | node != null for freed nodes |
| Coroutines | await | yield (deprecated) |
| Groups | Scene-specific groups | Global groups for everything |
| Autoloads | Services/managers only | Game logic in autoloads |
| Properties | Setters/getters | Direct mutation |
| Communication | Signal up, call down | Child calling parent methods |
Code Generation Guidelines
Script Structure
Order sections consistently:
class_name MyClass
extends Node2D
## Brief description of this class.
##
## Longer description if needed, explaining purpose and usage.
# === Signals ===
signal state_changed(new_state: State)
# === Enums ===
enum State { IDLE, RUNNING, JUMPING }
# === Exports ===
@export var speed: float = 100.0
@export_group("Combat")
@export var damage: int = 10
@export var attack_range: float = 50.0
# === Constants ===
const MAX_HEALTH: int = 100
# === Public Variables ===
var current_state: State = State.IDLE
# === Private Variables ===
var _internal_counter: int = 0
# === Onready ===
@onready var sprite: Sprite2D = $Sprite2D
@onready var collision: CollisionShape2D = $CollisionShape2D
# === Lifecycle Methods ===
func _ready() -> void:
pass
func _process(delta: float) -> void:
pass
func _physics_process(delta: float) -> void:
pass
# === Public Methods ===
func take_damage(amount: int) -> void:
pass
# === Private Methods ===
func _calculate_knockback() -> Vector2:
return Vector2.ZEROExport Annotations
Use exports for editor-configurable values:
# Basic exports
@export var health: int = 100
@export var speed: float = 200.0
@export var player_name: String = "Player"
# Range constraints
@export_range(0, 100) var percentage: int = 50
@export_range(0.0, 1.0, 0.1) var volume: float = 0.8
# Resource exports
@export var texture: Texture2D
@export var scene: PackedScene
@export var audio: AudioStream
# Grouped exports
@export_group("Movement")
@export var walk_speed: float = 100.0
@export var run_speed: float = 200.0
@export_group("Combat")
@export var attack_damage: int = 10
# Enum exports
@export var difficulty: Difficulty = Difficulty.NORMAL
enum Difficulty { EASY, NORMAL, HARD }
# Flags (multiselect)
@export_flags("Fire", "Water", "Earth", "Air") var elements: int = 0Common Game Patterns
State Machine (Overview)
Use enum-based state machines for simple cases:
enum State { IDLE, WALK, JUMP, ATTACK }
var current_state: State = State.IDLE
func _physics_process(delta: float) -> void:
match current_state:
State.IDLE:
_process_idle(delta)
State.WALK:
_process_walk(delta)
State.JUMP:
_process_jump(delta)
State.ATTACK:
_process_attack(delta)
func change_state(new_state: State) -> void:
if current_state == new_state:
return
_exit_state(current_state)
current_state = new_state
_enter_state(new_state)See references/patterns/state-machine.md for advanced implementations.
Object Pooling (Overview)
Reuse objects to avoid instantiation cost:
class_name ObjectPool
extends Node
var _pool: Array[Node] = []
var _scene: PackedScene
func _init(scene: PackedScene, initial_size: int = 10) -> void:
_scene = scene
for i in initial_size:
var obj := _scene.instantiate()
obj.set_process(false)
_pool.append(obj)
func acquire() -> Node:
if _pool.is_empty():
return _scene.instantiate()
var obj := _pool.pop_back()
obj.set_process(true)
return obj
func release(obj: Node) -> void:
obj.set_process(false)
_pool.append(obj)See references/patterns/object-pooling.md for complete implementation.
Save/Load (Overview)
Use Resources or JSON for save data:
# Custom Resource for save data
class_name SaveData
extends Resource
@export var player_position: Vector2
@export var player_health: int
@export var inventory: Array[String]
@export var level_name: String
# Save
func save_game(data: SaveData) -> void:
ResourceSaver.save(data, "user://save.tres")
# Load
func load_game() -> SaveData:
if ResourceLoader.exists("user://save.tres"):
return load("user://save.tres") as SaveData
return SaveData.new()See references/patterns/save-load-system.md for comprehensive guide.
Common Anti-Patterns
| Anti-Pattern | Problem | Solution |
|---|---|---|
Polling in _process | Wastes CPU on unchanged state | Use signals for state changes |
get_parent().get_parent() | Tight coupling, fragile | Signal up, or use groups |
Deep node paths $A/B/C/D | Breaks on refactor | Use %UniqueName |
load() in _process | Stuttering, memory churn | preload() or cache reference |
String signals emit_signal("x") | Typos, no autocomplete | Typed: signal_name.emit() |
Untyped @onready var x = $Node | Loses autocomplete | Always add type hint |
| Logic in autoloads | Testing difficulty, coupling | Keep autoloads thin |
| Magic numbers | Unclear meaning | Use const or @export |
node != null for freed nodes | Returns true for freed | Use is_instance_valid() |
| Circular dependencies | Load errors, unclear flow | Dependency injection or signals |
Additional Resources
Pattern Guides
references/patterns/state-machine.md- Full state machine implementationsreferences/patterns/object-pooling.md- Complete pooling systemreferences/patterns/save-load-system.md- Comprehensive save/load guidereferences/patterns/input-handling.md- Input buffering and rebinding
Architecture
references/architecture/project-structure.md- Directory organizationreferences/architecture/scene-composition.md- Scene design patternsreferences/architecture/node-communication.md- Signals vs direct calls
GDScript Deep Dives
references/gdscript/type-system.md- Static typing in depthreferences/gdscript/coroutines-await.md- Async patterns with await
Templates
assets/templates/base-script.gd.md- Standard script templateassets/templates/state-machine.gd.md- State machine templateassets/templates/autoload-manager.gd.md- Autoload singleton template
Limitations
- GDScript only (not C#, GDExtension, or VisualScript)
- Godot 4.x syntax (some patterns differ from 3.x)
- Game-focused patterns (not editor plugin development)
- No runtime validation scripts (GDScript requires Godot runtime)
Autoload Manager Template
Template for global singleton managers in Godot 4.x.
Usage
Register as autoload in Project Settings > Autoload. Keep autoloads thin - services only, not game logic.
Basic Manager Template
class_name ${ManagerName}Manager
extends Node
## Global ${description} manager.
##
## Access via ${ManagerName}Manager singleton.
# === Signals ===
signal ${event_occurred}(${data}: ${Type})
# === Private Variables ===
var _${internal_state}: ${Type}
# === Lifecycle ===
func _ready() -> void:
# Initialize manager state
pass
# === Public API ===
## ${Description of this method}
func ${public_method}(${param}: ${Type}) -> ${ReturnType}:
# Implementation
passAudio Manager Example
class_name AudioManager
extends Node
## Global audio playback manager.
##
## Handles music and sound effects with volume control.
# === Signals ===
signal music_changed(track_name: String)
# === Constants ===
const MUSIC_FADE_DURATION: float = 1.0
# === Exports ===
@export var music_bus: StringName = &"Music"
@export var sfx_bus: StringName = &"SFX"
# === Private Variables ===
var _music_player: AudioStreamPlayer
var _sfx_players: Array[AudioStreamPlayer] = []
var _current_music: String = ""
# === Lifecycle ===
func _ready() -> void:
_setup_music_player()
_setup_sfx_pool()
func _setup_music_player() -> void:
_music_player = AudioStreamPlayer.new()
_music_player.bus = music_bus
add_child(_music_player)
func _setup_sfx_pool() -> void:
for i in 8: # Pool of 8 SFX players
var player := AudioStreamPlayer.new()
player.bus = sfx_bus
add_child(player)
_sfx_players.append(player)
# === Music ===
func play_music(stream: AudioStream, fade_in: bool = true) -> void:
if _music_player.stream == stream and _music_player.playing:
return
_current_music = stream.resource_path
if fade_in and _music_player.playing:
await _fade_out_music()
_music_player.stream = stream
_music_player.play()
if fade_in:
await _fade_in_music()
music_changed.emit(_current_music)
func stop_music(fade_out: bool = true) -> void:
if fade_out:
await _fade_out_music()
_music_player.stop()
_current_music = ""
func _fade_out_music() -> void:
var tween := create_tween()
tween.tween_property(_music_player, "volume_db", -40.0, MUSIC_FADE_DURATION)
await tween.finished
func _fade_in_music() -> void:
_music_player.volume_db = -40.0
var tween := create_tween()
tween.tween_property(_music_player, "volume_db", 0.0, MUSIC_FADE_DURATION)
await tween.finished
# === Sound Effects ===
func play_sfx(stream: AudioStream, volume_db: float = 0.0) -> void:
var player := _get_available_sfx_player()
if player:
player.stream = stream
player.volume_db = volume_db
player.play()
func _get_available_sfx_player() -> AudioStreamPlayer:
for player in _sfx_players:
if not player.playing:
return player
return _sfx_players[0] # Fallback to first
# === Volume Control ===
func set_music_volume(linear: float) -> void:
AudioServer.set_bus_volume_db(
AudioServer.get_bus_index(music_bus),
linear_to_db(linear)
)
func set_sfx_volume(linear: float) -> void:
AudioServer.set_bus_volume_db(
AudioServer.get_bus_index(sfx_bus),
linear_to_db(linear)
)
func get_music_volume() -> float:
return db_to_linear(AudioServer.get_bus_volume_db(
AudioServer.get_bus_index(music_bus)
))
func get_sfx_volume() -> float:
return db_to_linear(AudioServer.get_bus_volume_db(
AudioServer.get_bus_index(sfx_bus)
))Event Bus Example
class_name EventBus
extends Node
## Global event bus for decoupled communication.
##
## Use for game-wide events that multiple systems care about.
# === Game State Events ===
signal game_started
signal game_paused
signal game_resumed
signal game_over
# === Player Events ===
signal player_spawned(player: Node2D)
signal player_died
signal player_respawned
signal player_health_changed(current: int, maximum: int)
# === Level Events ===
signal level_started(level_name: String)
signal level_completed(level_name: String)
signal checkpoint_reached(checkpoint_id: String)
# === Combat Events ===
signal damage_dealt(source: Node, target: Node, amount: int)
signal enemy_killed(enemy: Node, killer: Node)
# === UI Events ===
signal show_dialogue(dialogue_id: String)
signal hide_dialogue
signal show_notification(message: String)
# === Economy Events ===
signal currency_changed(amount: int, total: int)
signal item_purchased(item_id: String)
# Usage example:
# EventBus.player_died.emit()
# EventBus.damage_dealt.connect(_on_damage_dealt)Save Manager Example
class_name SaveManager
extends Node
## Global save/load manager.
signal save_completed(slot: int)
signal load_completed(slot: int)
signal save_failed(slot: int, error: String)
const SAVE_DIR := "user://saves/"
const SAVE_EXT := ".tres"
var current_slot: int = -1
func _ready() -> void:
DirAccess.make_dir_recursive_absolute(SAVE_DIR)
func save(slot: int, data: SaveData) -> Error:
var path := _get_save_path(slot)
var error := ResourceSaver.save(data, path)
if error == OK:
current_slot = slot
save_completed.emit(slot)
else:
save_failed.emit(slot, error_string(error))
return error
func load_save(slot: int) -> SaveData:
var path := _get_save_path(slot)
if not FileAccess.file_exists(path):
return null
var data := ResourceLoader.load(path) as SaveData
if data:
current_slot = slot
load_completed.emit(slot)
return data
func delete_save(slot: int) -> Error:
var path := _get_save_path(slot)
if FileAccess.file_exists(path):
return DirAccess.remove_absolute(path)
return OK
func save_exists(slot: int) -> bool:
return FileAccess.file_exists(_get_save_path(slot))
func get_all_saves() -> Array[Dictionary]:
var saves: Array[Dictionary] = []
for slot in range(1, 10):
if save_exists(slot):
var data := load_save(slot)
if data:
saves.append({
"slot": slot,
"timestamp": data.timestamp,
"level": data.current_level
})
return saves
func _get_save_path(slot: int) -> String:
return SAVE_DIR + "save_%02d%s" % [slot, SAVE_EXT]Best Practices
1. Keep autoloads thin - Logic goes in components, not managers 2. Use signals for events - Don't tightly couple systems 3. Avoid storing game state - Player health belongs on Player 4. Initialize in _ready - Not in _init 5. Document public API - What methods are for external use 6. Consider alternatives - Maybe you don't need an autoload
Base Script Template
Standard template for new GDScript files in Godot 4.x.
Usage
Copy and customize for new scripts. Replace all ${placeholders} with actual values. Remove unused sections.
Template
class_name ${ClassName}
extends ${ParentClass}
## ${Brief one-line description of this class.}
##
## ${Optional longer description explaining purpose, usage, and any
## important notes about how this class should be used.}
# === Signals ===
## Emitted when ${describe when signal fires}
signal ${signal_name}(${param}: ${Type})
# === Enums ===
enum ${EnumName} {
${VALUE_ONE},
${VALUE_TWO},
${VALUE_THREE},
}
# === Exports ===
@export_group("${Group Name}")
## ${Description of this property}
@export var ${property_name}: ${Type} = ${default_value}
@export_group("${Another Group}")
@export var ${another_property}: ${Type}
# === Constants ===
const ${CONSTANT_NAME}: ${Type} = ${value}
# === Public Variables ===
## ${Description of this public variable}
var ${public_var}: ${Type} = ${default}
# === Private Variables ===
var _${private_var}: ${Type}
var _${another_private}: ${Type} = ${default}
# === Onready References ===
@onready var _${node_ref}: ${NodeType} = $${NodePath}
@onready var _${unique_ref}: ${NodeType} = %${UniqueName}
# === Lifecycle Methods ===
func _ready() -> void:
${# Initialize state, connect signals}
pass
func _process(delta: float) -> void:
${# Called every frame}
pass
func _physics_process(delta: float) -> void:
${# Called every physics frame (fixed timestep)}
pass
func _input(event: InputEvent) -> void:
${# Handle input events}
pass
func _unhandled_input(event: InputEvent) -> void:
${# Handle input not consumed by UI}
pass
# === Public Methods ===
## ${Description of what this method does}
## ${param_name}: ${Description of parameter}
## Returns: ${Description of return value}
func ${public_method}(${param}: ${Type}) -> ${ReturnType}:
${# Implementation}
return ${value}
# === Private Methods ===
func _${private_method}() -> void:
${# Internal implementation}
pass
# === Signal Handlers ===
func _on_${signal_source}_${signal_name}(${params}) -> void:
${# Handle signal}
passSection Order
Keep sections in this order for consistency:
1. class_name and extends 2. Class documentation (##) 3. Signals 4. Enums 5. Exports (grouped with @export_group) 6. Constants 7. Public variables 8. Private variables (prefixed with _) 9. @onready references 10. Lifecycle methods (_ready, _process, etc.) 11. Public methods 12. Private methods (prefixed with _) 13. Signal handlers (prefixed with _on_)
Minimal Template
For simple scripts:
class_name ${ClassName}
extends ${ParentClass}
## ${Brief description}
func _ready() -> void:
passComponent Template
For reusable components:
class_name ${ComponentName}Component
extends Node
## ${Description of component purpose}
# === Signals ===
signal ${state_changed}(${new_value}: ${Type})
# === Exports ===
@export var ${configurable_value}: ${Type} = ${default}
# === Public Methods ===
func ${main_action}(${param}: ${Type}) -> void:
${# Component logic}
${state_changed}.emit(${new_value})Notes
- Always include
class_namefor discoverability - Use
##for documentation comments (shown in editor) - Use
#for implementation comments - Type everything: variables, parameters, return values
- Prefix private members with
_ - Use
@onreadyfor node references - Prefer
%UniqueNamefor stable references
State Machine Template
Enum-based state machine pattern for Godot 4.x.
Usage
Use for entities with distinct behavioral modes (idle, walking, attacking, etc.).
Template
class_name ${ClassName}
extends ${ParentClass}
## ${Description} with state machine behavior.
# === Signals ===
## Emitted when state changes
signal state_changed(old_state: State, new_state: State)
# === Enums ===
enum State {
${IDLE},
${MOVING},
${ATTACKING},
${HURT},
}
# === Exports ===
@export var initial_state: State = State.${IDLE}
# === Public Variables ===
var current_state: State:
set(value):
if current_state == value:
return
var old_state := current_state
_exit_state(current_state)
current_state = value
_enter_state(current_state)
state_changed.emit(old_state, current_state)
# === Private Variables ===
var _state_time: float = 0.0
# === Onready References ===
@onready var _animation_player: AnimationPlayer = $AnimationPlayer
@onready var _sprite: Sprite2D = $Sprite2D
# === Lifecycle Methods ===
func _ready() -> void:
current_state = initial_state
func _physics_process(delta: float) -> void:
_state_time += delta
_process_state(delta)
# === State Machine Core ===
func _enter_state(state: State) -> void:
_state_time = 0.0
match state:
State.${IDLE}:
_animation_player.play("idle")
State.${MOVING}:
_animation_player.play("move")
State.${ATTACKING}:
_animation_player.play("attack")
State.${HURT}:
_animation_player.play("hurt")
func _exit_state(state: State) -> void:
match state:
State.${IDLE}:
pass
State.${MOVING}:
pass
State.${ATTACKING}:
pass
State.${HURT}:
pass
func _process_state(delta: float) -> void:
match current_state:
State.${IDLE}:
_process_${idle}(delta)
State.${MOVING}:
_process_${moving}(delta)
State.${ATTACKING}:
_process_${attacking}(delta)
State.${HURT}:
_process_${hurt}(delta)
# === State Processors ===
func _process_${idle}(_delta: float) -> void:
# Check for transitions
if ${should_move}:
current_state = State.${MOVING}
elif ${should_attack}:
current_state = State.${ATTACKING}
func _process_${moving}(delta: float) -> void:
# Movement logic
${# velocity = direction * speed}
# Check for transitions
if ${should_stop}:
current_state = State.${IDLE}
elif ${should_attack}:
current_state = State.${ATTACKING}
func _process_${attacking}(_delta: float) -> void:
# Attack logic (usually wait for animation)
pass
func _process_${hurt}(_delta: float) -> void:
# Hurt logic (usually wait for animation)
pass
# === Signal Handlers ===
func _on_animation_player_animation_finished(anim_name: StringName) -> void:
match anim_name:
"attack":
current_state = State.${IDLE}
"hurt":
current_state = State.${IDLE}
# === Public Methods ===
## Force state change (useful for external events like taking damage)
func force_state(new_state: State) -> void:
current_state = new_state
## Check if entity is in a specific state
func is_in_state(state: State) -> bool:
return current_state == statePlayer Example
Complete player controller with state machine:
class_name Player
extends CharacterBody2D
## Player character with state machine movement.
signal died
enum State { IDLE, WALK, JUMP, ATTACK, HURT, DEAD }
@export var move_speed: float = 200.0
@export var jump_force: float = -400.0
@export var gravity: float = 980.0
var current_state: State = State.IDLE:
set(value):
if current_state == value:
return
_exit_state(current_state)
current_state = value
_enter_state(current_state)
var _input_direction: float = 0.0
@onready var _anim: AnimationPlayer = $AnimationPlayer
@onready var _sprite: Sprite2D = $Sprite2D
func _physics_process(delta: float) -> void:
_input_direction = Input.get_axis("move_left", "move_right")
_apply_gravity(delta)
_process_state(delta)
move_and_slide()
func _apply_gravity(delta: float) -> void:
if not is_on_floor():
velocity.y += gravity * delta
func _enter_state(state: State) -> void:
match state:
State.IDLE:
_anim.play("idle")
State.WALK:
_anim.play("walk")
State.JUMP:
velocity.y = jump_force
_anim.play("jump")
State.ATTACK:
velocity.x = 0
_anim.play("attack")
State.HURT:
velocity = Vector2(-_sprite.scale.x * 100, -200)
_anim.play("hurt")
State.DEAD:
velocity = Vector2.ZERO
_anim.play("death")
died.emit()
func _exit_state(_state: State) -> void:
pass
func _process_state(_delta: float) -> void:
match current_state:
State.IDLE:
velocity.x = 0
if _input_direction != 0:
current_state = State.WALK
elif Input.is_action_just_pressed("jump") and is_on_floor():
current_state = State.JUMP
elif Input.is_action_just_pressed("attack"):
current_state = State.ATTACK
State.WALK:
velocity.x = _input_direction * move_speed
_sprite.scale.x = sign(_input_direction) if _input_direction != 0 else _sprite.scale.x
if _input_direction == 0:
current_state = State.IDLE
elif Input.is_action_just_pressed("jump") and is_on_floor():
current_state = State.JUMP
elif Input.is_action_just_pressed("attack"):
current_state = State.ATTACK
State.JUMP:
velocity.x = _input_direction * move_speed
if is_on_floor():
current_state = State.IDLE
State.ATTACK, State.HURT:
pass # Wait for animation
State.DEAD:
pass # No processing
func take_damage(amount: int) -> void:
if current_state == State.DEAD:
return
# Apply damage, check death, etc.
current_state = State.HURT
func _on_animation_player_animation_finished(anim_name: StringName) -> void:
if anim_name == "attack" or anim_name == "hurt":
current_state = State.IDLENotes
- State changes through property setter for consistency
_enter_statehandles setup (animations, effects)_exit_statehandles cleanup_process_statehandles per-frame logic and transitions- Signal handlers can trigger state changes (animation finished)
- Consider extracting to separate State nodes for complex behavior
Node Communication Patterns
Guide to communication between nodes in Godot 4.x.
The Golden Rule
Signal up, call down.
- Parents know about children (can call their methods)
- Children don't know about parents (emit signals instead)
- Siblings communicate through shared parent or groups
Pattern 1: Signals (Decoupled Communication)
Best for: Events, state changes, child-to-parent communication.
# button.gd - Emits signal (doesn't know who listens)
class_name InteractButton
extends Area2D
signal pressed
func _on_body_entered(body: Node2D) -> void:
if body.is_in_group("player"):
pressed.emit()# door.gd - Connects to signal
class_name Door
extends Node2D
@export var button: InteractButton
func _ready() -> void:
if button:
button.pressed.connect(_on_button_pressed)
func _on_button_pressed() -> void:
open()Typed Signals (Godot 4.x)
# Define signals with typed parameters
signal health_changed(new_health: int, max_health: int)
signal item_collected(item: Item, collector: Node2D)
signal damage_dealt(target: Node2D, amount: int, type: DamageType)
# Emit with correct types
health_changed.emit(50, 100)
item_collected.emit(sword_item, player)Anonymous Signals (One-Time Use)
# Wait for animation to finish
await $AnimationPlayer.animation_finished
# Wait for timer
await get_tree().create_timer(1.0).timeout
# Wait for custom signal
await some_node.some_signalPattern 2: Direct Method Calls (Parent to Child)
Best for: Commands, immediate actions, when relationship is known.
# player.gd - Calls child methods directly
class_name Player
extends CharacterBody2D
@onready var weapon: Weapon = $Weapon
@onready var animator: AnimationPlayer = $AnimationPlayer
@onready var health: HealthComponent = $HealthComponent
func attack() -> void:
weapon.fire() # Direct call to child
animator.play("attack") # Direct call to child
func take_damage(amount: int) -> void:
health.damage(amount) # Direct call to childSafe Access with Optional Nodes
# Node might not exist in all scene variations
@onready var optional_shield: Shield = get_node_or_null("Shield")
func block() -> void:
if optional_shield:
optional_shield.activate()Pattern 3: Groups (Cross-Scene Communication)
Best for: Finding nodes across scene boundaries, one-to-many communication.
# Add nodes to groups in _ready() or editor
func _ready() -> void:
add_to_group("enemies")
add_to_group("damageable")# explosion.gd - Affects all nodes in group
func explode() -> void:
var blast_pos := global_position
for node in get_tree().get_nodes_in_group("damageable"):
var distance := node.global_position.distance_to(blast_pos)
if distance < blast_radius:
var damage := calculate_falloff_damage(distance)
node.take_damage(damage)Scene-Specific Groups
Prefix groups with scene identifier for isolation:
# In level_01
add_to_group("level_01_enemies")
# Clean up when level ends
func _exit_tree() -> void:
get_tree().call_group("level_01_enemies", "queue_free")Pattern 4: Autoloads (Global State)
Best for: Services, managers, truly global state.
# event_bus.gd - Global event system (autoload)
extends Node
signal player_died
signal level_completed(level_name: String)
signal achievement_unlocked(achievement_id: String)
# Any node can emit
# EventBus.player_died.emit()
# Any node can connect
# EventBus.player_died.connect(_on_player_died)When to Use Autoloads
| Use Case | Autoload? | Why |
|---|---|---|
| Audio playback | Yes | Persists between scenes |
| Save/load | Yes | Global service |
| Player stats | Maybe | Consider level-owned |
| Score | Maybe | Consider game state resource |
| Level manager | No | Scene should manage itself |
| Enemy spawning | No | Level-specific logic |
Pattern 5: Dependency Injection
Pass dependencies instead of hardcoding:
# weapon.gd - Receives owner, doesn't find it
class_name Weapon
extends Node2D
var _owner_stats: CharacterStats
func setup(stats: CharacterStats) -> void:
_owner_stats = stats
func calculate_damage() -> int:
return base_damage + _owner_stats.strength# player.gd - Injects dependency
func _ready() -> void:
$Weapon.setup(stats)Pattern 6: Callable/Lambda
Pass behavior as parameter:
# timer_utils.gd
static func delayed_call(node: Node, delay: float, callback: Callable) -> void:
await node.get_tree().create_timer(delay).timeout
callback.call()
# Usage
TimerUtils.delayed_call(self, 2.0, func(): print("2 seconds later"))
TimerUtils.delayed_call(self, 1.0, queue_free)Anti-Patterns to Avoid
Anti-Pattern 1: get_parent() Chains
# Bad: Fragile, breaks on refactor
var player = get_parent().get_parent().get_parent()
# Good: Use groups or signals
var player = get_tree().get_first_node_in_group("player")Anti-Pattern 2: Global find_node()
# Bad: Searches entire tree, slow and fragile
var enemy = get_tree().root.find_child("Enemy", true, false)
# Good: Use groups or explicit references
var enemies = get_tree().get_nodes_in_group("enemies")Anti-Pattern 3: Bidirectional References
# Bad: Circular reference, unclear ownership
# player.gd
var current_weapon: Weapon
# weapon.gd
var owner_player: Player
# Good: Parent references child, child signals parent
# player.gd
var weapon: Weapon
# weapon.gd
signal ammo_depleted # Player connects to thisAnti-Pattern 4: String-Based Signals (Legacy)
# Bad: No autocomplete, typo-prone (Godot 3.x style)
emit_signal("health_changed", 50)
connect("health_changed", self, "_on_health_changed")
# Good: Typed signals (Godot 4.x)
health_changed.emit(50)
health_changed.connect(_on_health_changed)Decision Guide
| Scenario | Pattern |
|---|---|
| Child notifies parent of state change | Signal |
| Parent commands child to act | Direct call |
| Siblings need to communicate | Signal through parent or group |
| Any node to any node | Group or autoload event bus |
| Waiting for something to happen | await signal |
| Global service (audio, saves) | Autoload |
| Need to find multiple nodes | Groups |
| Node needs external configuration | Dependency injection |
Performance Considerations
1. Signals are fast - Don't avoid them for performance 2. Groups are cached - get_nodes_in_group() is efficient 3. Avoid per-frame group queries - Cache references in _ready() 4. Direct calls are fastest - Use when relationship is stable
# Cache group results if queried frequently
var _cached_enemies: Array[Node]
func _ready() -> void:
_cached_enemies = get_tree().get_nodes_in_group("enemies")
get_tree().node_added.connect(_on_node_added)
get_tree().node_removed.connect(_on_node_removed)
func _on_node_added(node: Node) -> void:
if node.is_in_group("enemies"):
_cached_enemies.append(node)Project Structure
Recommended directory organization for Godot 4.x projects.
Standard Project Layout
project/
├── .godot/ # Godot cache (gitignore)
├── addons/ # Editor plugins and extensions
│ └── my_plugin/
├── assets/ # Non-code resources
│ ├── audio/
│ │ ├── music/
│ │ └── sfx/
│ ├── fonts/
│ ├── sprites/
│ │ ├── characters/
│ │ ├── environment/
│ │ └── ui/
│ ├── textures/
│ └── themes/
├── autoloads/ # Global singletons
│ ├── game_manager.gd
│ ├── audio_manager.gd
│ └── save_manager.gd
├── components/ # Reusable node components
│ ├── health_component.gd
│ ├── hitbox_component.gd
│ └── movement_component.gd
├── resources/ # Custom Resource definitions
│ ├── item_data.gd
│ ├── character_stats.gd
│ └── dialogue_data.gd
├── scenes/ # Game scenes
│ ├── actors/ # Characters, enemies, NPCs
│ │ ├── player/
│ │ │ ├── player.tscn
│ │ │ └── player.gd
│ │ └── enemies/
│ ├── levels/ # Level/world scenes
│ │ ├── level_01.tscn
│ │ └── level_02.tscn
│ ├── objects/ # Interactive objects
│ │ ├── door.tscn
│ │ └── chest.tscn
│ └── ui/ # UI scenes
│ ├── hud.tscn
│ ├── main_menu.tscn
│ └── pause_menu.tscn
├── scripts/ # Standalone scripts
│ ├── classes/ # Base classes
│ │ └── actor.gd
│ └── utils/ # Utility functions
│ └── math_utils.gd
├── shaders/ # Shader files
│ └── outline.gdshader
├── data/ # Static data files
│ ├── items.tres
│ └── enemies.tres
├── project.godot # Project settings
├── export_presets.cfg # Export configurations
└── .gitignoreDirectory Purposes
addons/
Third-party and custom editor plugins:
addons/
├── gut/ # Unit testing framework
├── dialogic/ # Dialogue system
└── my_custom_plugin/
├── plugin.cfg
└── plugin.gdautoloads/
Global singletons registered in Project Settings > Autoload:
- Keep autoloads thin (services, not game logic)
- One responsibility per autoload
- Prefer signals over direct method calls
# Good autoload: Manages audio globally
class_name AudioManager
extends Node
func play_sfx(sound: AudioStream) -> void:
# ...
# Bad autoload: Too much game logic
class_name GameManager
extends Node
var player_health: int # Should be on Player
var current_level: int # Should be on LevelManager
func spawn_enemy() -> void: # Should be on EnemySpawnercomponents/
Reusable node scripts that can be attached to any scene:
# health_component.gd
class_name HealthComponent
extends Node
signal health_changed(current: int, maximum: int)
signal died
@export var max_health: int = 100
var current_health: int
func take_damage(amount: int) -> void:
current_health = max(0, current_health - amount)
health_changed.emit(current_health, max_health)
if current_health <= 0:
died.emit()resources/
Custom Resource class definitions (not instances):
# item_data.gd
class_name ItemData
extends Resource
@export var id: String
@export var display_name: String
@export var icon: Texture2D
@export var stack_size: int = 99
@export_multiline var description: StringResource instances go in data/:
data/
├── items/
│ ├── sword.tres # ItemData instance
│ └── potion.tres
└── enemies/
├── slime.tres # EnemyData instance
└── goblin.tresscenes/
Organized by entity type, not by node type:
# Good: Organized by game entity
scenes/
├── actors/
│ └── player/
│ ├── player.tscn
│ ├── player.gd
│ └── player_states/
# Avoid: Organized by node type
scenes/
├── characterbody2d/
│ └── player.tscn
├── area2d/
│ └── hitbox.tscnscripts/
Scripts not attached to specific scenes:
- Base classes extended by scene scripts
- Utility functions
- Data structures
# scripts/classes/actor.gd
class_name Actor
extends CharacterBody2D
## Base class for all game characters
signal died
@export var move_speed: float = 100.0
var health_component: HealthComponent
func _ready() -> void:
health_component = get_node_or_null("HealthComponent")
if health_component:
health_component.died.connect(_on_died)Scene Organization Patterns
Co-located Scripts
Keep script next to its scene:
scenes/actors/player/
├── player.tscn
├── player.gd # Main player script
├── player_camera.gd # Camera control
└── player_animations.gdNested Scenes
Break complex scenes into sub-scenes:
# player.tscn contains:
Player (CharacterBody2D)
├── CollisionShape2D
├── Sprite2D
├── AnimationPlayer
├── WeaponMount (Node2D)
│ └── weapon.tscn (instanced)
├── HealthComponent (health_component.tscn)
└── StateMachine
└── [states as child nodes]Naming Conventions
| Type | Convention | Example |
|---|---|---|
| Folders | snake_case | player_states/ |
| Scenes | snake_case.tscn | main_menu.tscn |
| Scripts | snake_case.gd | player_controller.gd |
| Resources | snake_case.tres | fire_sword.tres |
| Shaders | snake_case.gdshader | water_ripple.gdshader |
| Images | snake_case.png | player_idle.png |
| Audio | snake_case.wav/ogg | jump_sound.wav |
Git Configuration
Recommended .gitignore:
# Godot cache
.godot/
# Exports
*.pck
*.zip
build/
# OS files
.DS_Store
Thumbs.db
# Editor backups
*.import.bak
# IDE
.vscode/
*.code-workspaceImport Presets
Configure import settings for asset types in project.godot or via .import files:
# Pixel art project - disable filtering
[preset.0]
name="Pixel Art Texture"
platform="*"
filter=false
mipmaps=falseBest Practices
1. One scene, one responsibility - Split complex scenes 2. Co-locate related files - Script next to scene 3. Use Resources for data - Not scripts with const values 4. Avoid deep nesting - Max 3-4 levels deep 5. Consistent naming - Same conventions everywhere 6. Version control friendly - Text-based resources (.tres not .res) 7. Document non-obvious structure - README in complex folders
Scene Composition Patterns
Guide to designing and organizing Godot scenes for maintainability and reusability.
Core Principles
1. Scenes as prefabs - Reusable, self-contained units 2. Composition over inheritance - Combine small scenes, don't extend large ones 3. Single responsibility - Each scene does one thing well 4. Loose coupling - Scenes communicate through signals
Pattern 1: Component Composition
Build complex entities from simple component scenes:
# Entity composed of components
Player.tscn
├── CharacterBody2D (player.gd)
│ ├── Sprite2D
│ ├── CollisionShape2D
│ ├── AnimationPlayer
│ ├── HealthComponent (health_component.tscn)
│ ├── HitboxComponent (hitbox_component.tscn)
│ ├── MovementComponent (movement_component.tscn)
│ └── InventoryComponent (inventory_component.tscn)# player.gd - Orchestrates components
class_name Player
extends CharacterBody2D
@onready var health: HealthComponent = $HealthComponent
@onready var hitbox: HitboxComponent = $HitboxComponent
@onready var movement: MovementComponent = $MovementComponent
func _ready() -> void:
health.died.connect(_on_died)
hitbox.hit_received.connect(health.take_damage)
func _physics_process(delta: float) -> void:
var input_dir := Input.get_vector("left", "right", "up", "down")
movement.move(input_dir, delta)
move_and_slide()# health_component.gd - Reusable component
class_name HealthComponent
extends Node
signal health_changed(current: int, maximum: int)
signal died
@export var max_health: int = 100
var current_health: int:
set(value):
current_health = clamp(value, 0, max_health)
health_changed.emit(current_health, max_health)
if current_health <= 0:
died.emit()
func _ready() -> void:
current_health = max_health
func take_damage(amount: int) -> void:
current_health -= amount
func heal(amount: int) -> void:
current_health += amountPattern 2: Scene Inheritance
Extend base scenes for variations:
# Base enemy scene
enemy_base.tscn
├── CharacterBody2D (enemy_base.gd)
│ ├── Sprite2D
│ ├── CollisionShape2D
│ ├── HealthComponent
│ └── NavigationAgent2D
# Inherited scenes override properties
slime.tscn (inherits enemy_base.tscn)
├── [Sprite2D with slime texture]
├── [CollisionShape2D resized]
└── slime.gd (extends EnemyBase)
goblin.tscn (inherits enemy_base.tscn)
├── [Sprite2D with goblin texture]
├── WeaponMount (added node)
└── goblin.gd (extends EnemyBase)# enemy_base.gd
class_name EnemyBase
extends CharacterBody2D
@export var move_speed: float = 50.0
@export var damage: int = 10
@onready var nav_agent: NavigationAgent2D = $NavigationAgent2D
@onready var health: HealthComponent = $HealthComponent
func _ready() -> void:
health.died.connect(_on_died)
func _physics_process(delta: float) -> void:
_move_toward_target(delta)
func _move_toward_target(delta: float) -> void:
# Base movement logic
pass
func _on_died() -> void:
queue_free()# slime.gd - Extends base with specific behavior
class_name Slime
extends EnemyBase
@export var split_count: int = 2
func _on_died() -> void:
_spawn_smaller_slimes()
super._on_died()
func _spawn_smaller_slimes() -> void:
if split_count > 0:
# Spawn logic
passPattern 3: Container Scenes
Scenes that manage child scenes dynamically:
# level.gd - Container that loads/manages sub-scenes
class_name Level
extends Node2D
@export var enemy_spawns: Array[PackedScene] = []
@onready var enemy_container: Node2D = $Enemies
@onready var pickup_container: Node2D = $Pickups
func _ready() -> void:
_spawn_enemies()
func _spawn_enemies() -> void:
for spawn_point in $SpawnPoints.get_children():
var enemy_scene: PackedScene = enemy_spawns.pick_random()
var enemy := enemy_scene.instantiate()
enemy.position = spawn_point.position
enemy_container.add_child(enemy)
func add_pickup(pickup: Node2D, position: Vector2) -> void:
pickup.position = position
pickup_container.add_child(pickup)Pattern 4: Owner Access
Child scenes can access their owner for context:
# weapon.gd - Attached to weapon.tscn, instanced in player
class_name Weapon
extends Node2D
var wielder: CharacterBody2D
func _ready() -> void:
# Owner is the root of the scene this was instanced into
wielder = owner as CharacterBody2D
if not wielder:
push_error("Weapon must be child of CharacterBody2D")
func attack() -> void:
var direction := wielder.global_transform.x
# Use wielder's position, stats, etc.Pattern 5: Unique Names
Use % for stable references to important nodes:
Player.tscn
├── CharacterBody2D
│ ├── %Sprite (unique name)
│ ├── %AnimationPlayer (unique name)
│ ├── UI
│ │ └── %HealthBar (unique name)
│ └── Weapons
│ └── %CurrentWeapon (unique name)# Access via % regardless of hierarchy
@onready var sprite: Sprite2D = %Sprite
@onready var anim: AnimationPlayer = %AnimationPlayer
@onready var health_bar: ProgressBar = %HealthBar
@onready var weapon: Weapon = %CurrentWeapon
# Works even if UI node is renamed or movedScene Communication Patterns
Signals (Preferred)
# Child emits, parent connects
# health_component.gd
signal health_changed(current: int, max: int)
# player.gd
func _ready() -> void:
$HealthComponent.health_changed.connect(_update_health_bar)Direct Calls (When Appropriate)
# Parent calls child methods (knows about children)
func attack() -> void:
$Weapon.fire()
$AnimationPlayer.play("attack")Groups (Cross-Scene Communication)
# Any node can find others in same group
func _on_explosion() -> void:
for enemy in get_tree().get_nodes_in_group("enemies"):
if enemy.global_position.distance_to(position) < blast_radius:
enemy.take_damage(damage)Scene Checklist
Before creating a new scene:
- [ ] Can it be reused elsewhere?
- [ ] Does it have a single responsibility?
- [ ] Are dependencies injected (not hardcoded)?
- [ ] Does it communicate via signals?
- [ ] Is the root node type appropriate?
- [ ] Are exported properties documented?
Common Mistakes
| Mistake | Problem | Solution |
|---|---|---|
| Deep node paths | Fragile to refactoring | Use %UniqueName |
| Direct parent access | Tight coupling | Use signals |
| God scenes | Hard to maintain | Split into components |
| Missing null checks | Crashes | Use get_node_or_null() |
| Circular references | Memory leaks | Weak references or signals |
Best Practices
1. Start simple, add complexity - Don't over-engineer upfront 2. Test scenes in isolation - Each scene should work alone 3. Document public API - What signals/methods are for external use 4. Use tool mode for preview - @tool scripts show in editor 5. Keep scene tree shallow - Avoid deeply nested hierarchies
Coroutines and Await
Guide to asynchronous programming with await in GDScript 2.0 (Godot 4.x).
Await Basics
await pauses function execution until a signal is emitted or coroutine completes.
# Wait for signal
await some_signal
# Wait for timer
await get_tree().create_timer(1.0).timeout
# Wait for animation
await $AnimationPlayer.animation_finished
# Wait for coroutine
await some_async_function()Pattern 1: Simple Delays
func flash_damage() -> void:
$Sprite.modulate = Color.RED
await get_tree().create_timer(0.1).timeout
$Sprite.modulate = Color.WHITE
func spawn_with_delay(delay: float) -> void:
await get_tree().create_timer(delay).timeout
var enemy := ENEMY_SCENE.instantiate()
add_child(enemy)
func countdown() -> void:
for i in range(3, 0, -1):
$Label.text = str(i)
await get_tree().create_timer(1.0).timeout
$Label.text = "GO!"Pattern 2: Animation Sequences
func play_attack_sequence() -> void:
# Play animation and wait for it
$AnimationPlayer.play("wind_up")
await $AnimationPlayer.animation_finished
# Deal damage at the right moment
deal_damage()
$AnimationPlayer.play("swing")
await $AnimationPlayer.animation_finished
$AnimationPlayer.play("recover")
await $AnimationPlayer.animation_finished
# Animation sequence complete
attack_finished.emit()
func death_sequence() -> void:
# Disable gameplay
set_physics_process(false)
$CollisionShape2D.disabled = true
# Play death animation
$AnimationPlayer.play("death")
await $AnimationPlayer.animation_finished
# Fade out
var tween := create_tween()
tween.tween_property($Sprite, "modulate:a", 0.0, 0.5)
await tween.finished
queue_free()Pattern 3: Tweens
func move_to(target_pos: Vector2) -> void:
var tween := create_tween()
tween.tween_property(self, "position", target_pos, 0.5)
await tween.finished
func fade_in() -> void:
modulate.a = 0.0
var tween := create_tween()
tween.tween_property(self, "modulate:a", 1.0, 0.3)
await tween.finished
func bounce_scale() -> void:
var tween := create_tween()
tween.tween_property(self, "scale", Vector2(1.2, 1.2), 0.1)
tween.tween_property(self, "scale", Vector2(1.0, 1.0), 0.1)
await tween.finishedPattern 4: Waiting for Signals
func wait_for_player_input() -> void:
# Pause until player presses button
await $Button.pressed
continue_dialogue()
func wait_for_any_key() -> void:
# Custom signal from input handling
await any_key_pressed
start_game()
func wait_for_player_death() -> void:
var player := get_tree().get_first_node_in_group("player")
await player.died
show_game_over()Pattern 5: Chained Coroutines
# Coroutines can await other coroutines
func level_transition() -> void:
await fade_out()
await load_next_level()
await fade_in()
func fade_out() -> void:
var tween := create_tween()
tween.tween_property($FadeOverlay, "modulate:a", 1.0, 0.5)
await tween.finished
func load_next_level() -> void:
# Simulate loading
await get_tree().create_timer(0.5).timeout
get_tree().change_scene_to_file(_next_level_path)
func fade_in() -> void:
var tween := create_tween()
tween.tween_property($FadeOverlay, "modulate:a", 0.0, 0.5)
await tween.finishedPattern 6: Parallel Operations
# Wait for multiple things using signals
func spawn_wave() -> void:
var enemies: Array[Enemy] = []
# Spawn all enemies
for i in 5:
var enemy := spawn_enemy()
enemies.append(enemy)
# Wait for all to die
for enemy in enemies:
await enemy.died
wave_complete.emit()
# Or use a counter
var _enemies_alive: int = 0
func spawn_wave_with_counter() -> void:
for i in 5:
var enemy := spawn_enemy()
enemy.died.connect(_on_enemy_died)
_enemies_alive += 1
func _on_enemy_died() -> void:
_enemies_alive -= 1
if _enemies_alive == 0:
wave_complete.emit()Pattern 7: Interruptible Coroutines
var _current_tween: Tween
func move_to_interruptible(target: Vector2) -> void:
# Cancel previous movement
if _current_tween and _current_tween.is_valid():
_current_tween.kill()
_current_tween = create_tween()
_current_tween.tween_property(self, "position", target, 0.5)
await _current_tween.finished
# Using a flag
var _is_dashing: bool = false
func dash() -> void:
if _is_dashing:
return
_is_dashing = true
var dash_target := position + facing * DASH_DISTANCE
var tween := create_tween()
tween.tween_property(self, "position", dash_target, DASH_DURATION)
await tween.finished
_is_dashing = falsePattern 8: Timeout Pattern
# Wait for signal with timeout
func wait_with_timeout(sig: Signal, timeout: float) -> bool:
var timer := get_tree().create_timer(timeout)
# Race between signal and timeout
var result = await Promise.race([sig, timer.timeout])
return result != timer # True if signal won
# Simpler approach with custom signal
signal _timeout_or_result(succeeded: bool)
func wait_for_response(timeout: float) -> bool:
# Start timeout timer
var timer := get_tree().create_timer(timeout)
timer.timeout.connect(func(): _timeout_or_result.emit(false))
# Connect to actual signal
response_received.connect(func(): _timeout_or_result.emit(true), CONNECT_ONE_SHOT)
var result: bool = await _timeout_or_result
return resultCommon Gotchas
Gotcha 1: Node Freed During Await
# Dangerous - node might be freed
func risky_function() -> void:
await get_tree().create_timer(5.0).timeout
$Sprite.visible = false # Crash if node freed!
# Safe - check validity
func safe_function() -> void:
await get_tree().create_timer(5.0).timeout
if is_instance_valid(self):
$Sprite.visible = falseGotcha 2: Multiple Awaits on Same Coroutine
# Each await creates new execution
func do_thing() -> void:
await get_tree().create_timer(1.0).timeout
print("Done")
# Calling multiple times runs concurrently!
func _ready() -> void:
do_thing() # Starts coroutine 1
do_thing() # Starts coroutine 2 immediately
do_thing() # Starts coroutine 3 immediately
# All three print "Done" after 1 secondGotcha 3: Await in _process
# BAD - creates new coroutine every frame
func _process(_delta: float) -> void:
await get_tree().create_timer(1.0).timeout # Don't do this!
# GOOD - use flag or state
var _is_waiting: bool = false
func _process(_delta: float) -> void:
if not _is_waiting and should_wait:
_start_wait()
func _start_wait() -> void:
_is_waiting = true
await get_tree().create_timer(1.0).timeout
_is_waiting = falseGotcha 4: Return Value from Coroutine
# Coroutines can return values
func load_data() -> Dictionary:
await get_tree().create_timer(0.1).timeout # Simulate load
return {"key": "value"}
# Must await to get return value
func _ready() -> void:
var data: Dictionary = await load_data()
print(data) # {"key": "value"}
# Without await, you get a coroutine object
var wrong = load_data() # GDScriptFunctionState, not Dictionary!Best Practices
1. Check `is_instance_valid()` after long awaits 2. Use flags to prevent concurrent coroutines 3. Cancel tweens before starting new ones 4. Avoid await in `_process()` - use states instead 5. Keep coroutines short - long chains are hard to debug 6. Use signals for complex async flows 7. Handle interruption - what happens if node freed?
Migration from Godot 3.x
| Godot 3.x | Godot 4.x |
|---|---|
yield(timer, "timeout") | await timer.timeout |
yield(anim, "animation_finished") | await anim.animation_finished |
yield(get_tree().create_timer(1), "timeout") | await get_tree().create_timer(1).timeout |
yield(coroutine()) | await coroutine() |
Returns GDScriptFunctionState | Returns signal or coroutine |
GDScript Type System
Complete guide to static typing in GDScript 2.0 (Godot 4.x).
Why Use Static Typing
1. Editor autocomplete - Better suggestions and method lookup 2. Compile-time errors - Catch type mismatches before running 3. Performance - Typed code can be optimized 4. Documentation - Types explain expected values 5. Refactoring - IDE can safely rename and modify
Basic Type Annotations
Variables
# Explicit type declaration
var health: int = 100
var speed: float = 200.0
var player_name: String = "Player"
var is_alive: bool = true
# Type inference (inferred from value)
var score := 0 # int
var velocity := Vector2.ZERO # Vector2
# Nullable types (can be null)
var target: Node2D = null
var current_weapon: Weapon = null
# Constants (always inferred or explicit)
const MAX_HEALTH: int = 100
const GRAVITY: float = 980.0Functions
# Explicit parameter and return types
func calculate_damage(base: int, multiplier: float) -> int:
return int(base * multiplier)
# Void return (no return value)
func take_damage(amount: int) -> void:
health -= amount
# Optional parameters with defaults
func spawn_enemy(position: Vector2, health: int = 100) -> Enemy:
var enemy := Enemy.new()
enemy.position = position
enemy.health = health
return enemy
# No explicit return type (returns Variant)
func get_data(): # Avoid - always add return type
return _dataBuilt-in Types
Primitive Types
var integer: int = 42
var floating: float = 3.14
var text: String = "Hello"
var flag: bool = trueVector Types
var pos2d: Vector2 = Vector2(100, 200)
var pos3d: Vector3 = Vector3(1, 2, 3)
var pos4d: Vector4 = Vector4(1, 2, 3, 4)
var int_pos: Vector2i = Vector2i(10, 20) # Integer vector
var int_pos3: Vector3i = Vector3i(1, 2, 3)Color and Transform
var tint: Color = Color.RED
var xform2d: Transform2D = Transform2D.IDENTITY
var xform3d: Transform3D = Transform3D.IDENTITY
var basis: Basis = Basis.IDENTITYCollections
# Typed arrays (Godot 4.x)
var numbers: Array[int] = [1, 2, 3]
var names: Array[String] = ["Alice", "Bob"]
var enemies: Array[Enemy] = []
var nodes: Array[Node] = []
# Untyped array (avoid when possible)
var mixed: Array = [1, "two", 3.0]
# Dictionary (keys and values are Variant)
var data: Dictionary = {"key": "value"}
# PackedArrays (memory-efficient)
var bytes: PackedByteArray = PackedByteArray([0, 1, 2])
var ints: PackedInt32Array = PackedInt32Array([1, 2, 3])
var floats: PackedFloat32Array = PackedFloat32Array([1.0, 2.0])
var strings: PackedStringArray = PackedStringArray(["a", "b"])
var vectors: PackedVector2Array = PackedVector2Array([Vector2.ZERO])Class Types
Custom Classes
# Define class with class_name
class_name Player
extends CharacterBody2D
# Use as type
var player: Player
var players: Array[Player] = []
func get_player() -> Player:
return playerNode Types
# Use specific node types
@onready var sprite: Sprite2D = $Sprite2D
@onready var collision: CollisionShape2D = $CollisionShape2D
@onready var animation: AnimationPlayer = $AnimationPlayer
@onready var audio: AudioStreamPlayer = $AudioStreamPlayer
# Generic Node when type varies
@onready var child: Node = $SomeChildResource Types
# Specific resource types
@export var texture: Texture2D
@export var scene: PackedScene
@export var audio: AudioStream
@export var font: Font
# Custom resource
@export var item_data: ItemData
@export var character_stats: CharacterStatsType Casting
Safe Casting with as
# Returns null if cast fails (safe)
func _on_body_entered(body: Node2D) -> void:
var player := body as Player
if player:
player.collect_item(self)
# Also works with is check
func _on_area_entered(area: Area2D) -> void:
if area is Hitbox:
var hitbox := area as Hitbox
take_damage(hitbox.damage)Type Checking with is
func process_node(node: Node) -> void:
if node is CharacterBody2D:
# node is narrowed to CharacterBody2D in this block
node.move_and_slide()
if node is Enemy:
node.take_damage(10)
elif node is Player:
node.add_score(100)Typed Signals
# Signal with typed parameters
signal health_changed(current: int, maximum: int)
signal target_acquired(target: Node2D, distance: float)
signal item_collected(item: ItemData, amount: int)
# Emit with correct types
func take_damage(amount: int) -> void:
_health -= amount
health_changed.emit(_health, _max_health)
# Connect with typed callable
func _ready() -> void:
health_changed.connect(_on_health_changed)
func _on_health_changed(current: int, maximum: int) -> void:
health_bar.value = float(current) / maximumEnums
# Define enum
enum State { IDLE, WALK, JUMP, ATTACK }
enum DamageType { PHYSICAL, FIRE, ICE, ELECTRIC }
# Use as type
var current_state: State = State.IDLE
var damage_type: DamageType = DamageType.PHYSICAL
# In functions
func change_state(new_state: State) -> void:
current_state = new_state
func apply_damage(amount: int, type: DamageType) -> void:
match type:
DamageType.FIRE:
# Apply burning
pass
DamageType.ICE:
# Apply slow
passAdvanced Patterns
Nullable Types
# Node references can be null
var target: Enemy = null
func find_target() -> void:
target = _find_nearest_enemy()
func attack() -> void:
if target and is_instance_valid(target):
target.take_damage(damage)Union-like Types (Variant)
# When multiple types are valid, use Variant
func get_config_value(key: String) -> Variant:
return _config.get(key)
# Or use method overloading pattern
func set_property_int(key: String, value: int) -> void:
_properties[key] = value
func set_property_string(key: String, value: String) -> void:
_properties[key] = valueGeneric-like Patterns
# Typed array in class
class_name Inventory
extends Node
var _items: Array[Item] = []
func add_item(item: Item) -> void:
_items.append(item)
func get_items() -> Array[Item]:
return _items.duplicate()
func find_by_type(item_type: Item.Type) -> Array[Item]:
return _items.filter(func(item: Item) -> bool:
return item.type == item_type
)Common Gotchas
Array Type Covariance
# This doesn't work as expected
var enemies: Array[Enemy] = []
var nodes: Array[Node] = enemies # Error! Arrays are invariant
# Work around by copying
var nodes: Array[Node] = []
for enemy in enemies:
nodes.append(enemy)Null vs Invalid Instance
# Node was freed but reference still exists
var enemy: Enemy = $Enemy
func _process(_delta: float) -> void:
# enemy != null is true even if freed!
if enemy: # This passes for freed nodes
enemy.update() # Crash!
# Correct way
if is_instance_valid(enemy):
enemy.update()Export Type Mismatch
# Export must match variable type
@export var speed: float = 100.0 # Good
@export var speed: float = 100 # Error: 100 is int
# Use explicit float
@export var speed: float = 100.0Type Annotation Cheatsheet
| Declaration | Syntax | Example |
|---|---|---|
| Variable | var x: Type | var health: int = 100 |
| Inferred | var x := value | var pos := Vector2.ZERO |
| Constant | const X: Type = val | const MAX: int = 100 |
| Parameter | func f(x: Type) | func move(dir: Vector2) |
| Return | func f() -> Type | func get_hp() -> int |
| Array | Array[Type] | Array[Enemy] |
| Nullable | var x: Type = null | var target: Node = null |
| Cast | x as Type | body as Player |
| Check | x is Type | if node is Enemy |
Best Practices
1. Always type public API - Parameters, returns, signals 2. Use `:=` for inference - When type is obvious from value 3. Prefer specific types - Sprite2D over Node 4. Use `is_instance_valid()` - For node references 5. Type signal parameters - Better documentation 6. Use typed arrays - Array[Enemy] not Array 7. Cast safely with `as` - Returns null on failure
Input Handling Patterns
Complete guide to implementing robust input systems in Godot 4.x.
Input System Basics
Godot's input system provides:
- Input Map: Named actions mapped to keys/buttons
- Input singleton: Query current input state
- _input()/_unhandled_input(): Event-based handling
Pattern 1: Action-Based Input
Always use Input Map actions instead of raw key codes:
# Project Settings > Input Map defines:
# - move_left: A, Left Arrow, Gamepad Left
# - move_right: D, Right Arrow, Gamepad Right
# - jump: Space, Gamepad A
# - attack: Mouse Left, Gamepad X
func _physics_process(_delta: float) -> void:
# Axis input (returns -1 to 1)
var input_dir := Input.get_axis("move_left", "move_right")
velocity.x = input_dir * speed
# 2D vector input
var move_vector := Input.get_vector(
"move_left", "move_right",
"move_up", "move_down"
)
# Button states
if Input.is_action_just_pressed("jump"):
jump()
if Input.is_action_pressed("attack"):
charge_attack()
if Input.is_action_just_released("attack"):
release_attack()
func _unhandled_input(event: InputEvent) -> void:
# Event-based handling (good for one-shot actions)
if event.is_action_pressed("pause"):
toggle_pause()
get_viewport().set_input_as_handled()Pattern 2: Input Buffer
Buffer inputs for responsive controls (fighting games, platformers):
class_name InputBuffer
extends Node
## How long inputs stay in buffer (seconds)
@export var buffer_duration: float = 0.15
var _buffer: Dictionary = {} # action_name -> timestamp
func _process(_delta: float) -> void:
_update_buffer()
func _update_buffer() -> void:
var current_time := Time.get_ticks_msec() / 1000.0
# Add new inputs to buffer
for action in ["jump", "attack", "dash"]:
if Input.is_action_just_pressed(action):
_buffer[action] = current_time
# Remove expired inputs
var expired: Array[String] = []
for action in _buffer:
if current_time - _buffer[action] > buffer_duration:
expired.append(action)
for action in expired:
_buffer.erase(action)
## Check if action is buffered (and consume it)
func consume(action: String) -> bool:
if _buffer.has(action):
_buffer.erase(action)
return true
return false
## Check if action is buffered (without consuming)
func is_buffered(action: String) -> bool:
return _buffer.has(action)
## Clear specific action from buffer
func clear(action: String) -> void:
_buffer.erase(action)
## Clear all buffered inputs
func clear_all() -> void:
_buffer.clear()Usage:
@onready var input_buffer: InputBuffer = $InputBuffer
func _physics_process(_delta: float) -> void:
# Player can press jump slightly before landing
if is_on_floor() and input_buffer.consume("jump"):
jump()
# Coyote time: Can jump briefly after leaving platform
if _was_on_floor and not is_on_floor():
_coyote_timer = COYOTE_TIME
if _coyote_timer > 0 and input_buffer.consume("jump"):
jump()Pattern 3: Input State Machine
Different input contexts for different game states:
class_name InputContext
extends RefCounted
var _actions: Dictionary = {} # action_name -> Callable
func bind(action: String, callback: Callable) -> InputContext:
_actions[action] = callback
return self
func handle_input(event: InputEvent) -> bool:
for action in _actions:
if event.is_action_pressed(action):
_actions[action].call()
return true
return false
func handle_process() -> void:
# For continuous input (movement)
passclass_name InputManager
extends Node
var _contexts: Array[InputContext] = []
var _active_context: InputContext
func push_context(context: InputContext) -> void:
_contexts.push_back(context)
_active_context = context
func pop_context() -> InputContext:
if _contexts.is_empty():
return null
var popped := _contexts.pop_back()
_active_context = _contexts.back() if not _contexts.is_empty() else null
return popped
func _unhandled_input(event: InputEvent) -> void:
if _active_context and _active_context.handle_input(event):
get_viewport().set_input_as_handled()Usage:
# Define contexts
var gameplay_context := InputContext.new() \
.bind("jump", _on_jump) \
.bind("attack", _on_attack) \
.bind("pause", _on_pause)
var menu_context := InputContext.new() \
.bind("ui_accept", _on_menu_select) \
.bind("ui_cancel", _on_menu_back) \
.bind("pause", _on_unpause)
func _ready() -> void:
InputManager.push_context(gameplay_context)
func _on_pause() -> void:
InputManager.push_context(menu_context)
get_tree().paused = true
func _on_unpause() -> void:
InputManager.pop_context()
get_tree().paused = falsePattern 4: Rebindable Controls
Allow players to customize controls:
class_name InputRemapper
extends Node
const SAVE_PATH := "user://input_config.cfg"
# Default mappings backup
var _default_mappings: Dictionary = {}
func _ready() -> void:
_save_defaults()
load_custom_mappings()
func _save_defaults() -> void:
for action in InputMap.get_actions():
if action.begins_with("ui_"):
continue # Skip built-in UI actions
_default_mappings[action] = InputMap.action_get_events(action).duplicate()
func remap_action(action: String, event: InputEvent) -> void:
# Clear existing mappings
InputMap.action_erase_events(action)
# Add new mapping
InputMap.action_add_event(action, event)
# Save to disk
save_custom_mappings()
func reset_action(action: String) -> void:
if _default_mappings.has(action):
InputMap.action_erase_events(action)
for event in _default_mappings[action]:
InputMap.action_add_event(action, event)
save_custom_mappings()
func reset_all() -> void:
for action in _default_mappings:
reset_action(action)
func save_custom_mappings() -> void:
var config := ConfigFile.new()
for action in InputMap.get_actions():
if action.begins_with("ui_"):
continue
var events := InputMap.action_get_events(action)
for i in events.size():
var event := events[i]
var key := "%s_%d" % [action, i]
if event is InputEventKey:
config.set_value("keys", key, event.keycode)
elif event is InputEventMouseButton:
config.set_value("mouse", key, event.button_index)
elif event is InputEventJoypadButton:
config.set_value("joypad_button", key, event.button_index)
elif event is InputEventJoypadMotion:
config.set_value("joypad_axis", key, {
"axis": event.axis,
"value": event.axis_value
})
config.save(SAVE_PATH)
func load_custom_mappings() -> void:
var config := ConfigFile.new()
if config.load(SAVE_PATH) != OK:
return
# Clear current and apply saved
for action in _default_mappings:
InputMap.action_erase_events(action)
# Load keyboard
for key in config.get_section_keys("keys"):
var action := key.rsplit("_", true, 1)[0]
var keycode: int = config.get_value("keys", key)
var event := InputEventKey.new()
event.keycode = keycode
InputMap.action_add_event(action, event)
# Load mouse buttons
for key in config.get_section_keys("mouse"):
var action := key.rsplit("_", true, 1)[0]
var button: int = config.get_value("mouse", key)
var event := InputEventMouseButton.new()
event.button_index = button
InputMap.action_add_event(action, event)
# Similar for joypad...Pattern 5: Combo System
Detect input sequences (fighting game combos):
class_name ComboDetector
extends Node
signal combo_detected(combo_name: String)
@export var combo_window: float = 0.5 # Time between inputs
var _input_history: Array[Dictionary] = [] # {action, timestamp}
var _combos: Dictionary = {} # combo_name -> Array[String]
func register_combo(name: String, sequence: Array[String]) -> void:
_combos[name] = sequence
func _unhandled_input(event: InputEvent) -> void:
for action in ["up", "down", "left", "right", "punch", "kick"]:
if event.is_action_pressed(action):
_record_input(action)
_check_combos()
func _record_input(action: String) -> void:
var current_time := Time.get_ticks_msec() / 1000.0
# Remove old inputs
_input_history = _input_history.filter(
func(entry): return current_time - entry.timestamp < combo_window
)
_input_history.append({
"action": action,
"timestamp": current_time
})
func _check_combos() -> void:
var recent_actions: Array[String] = []
for entry in _input_history:
recent_actions.append(entry.action)
for combo_name in _combos:
var sequence: Array = _combos[combo_name]
if _ends_with_sequence(recent_actions, sequence):
combo_detected.emit(combo_name)
_input_history.clear() # Consume inputs
break
func _ends_with_sequence(history: Array, sequence: Array) -> bool:
if history.size() < sequence.size():
return false
var start := history.size() - sequence.size()
for i in sequence.size():
if history[start + i] != sequence[i]:
return false
return trueUsage:
@onready var combo_detector: ComboDetector = $ComboDetector
func _ready() -> void:
combo_detector.register_combo("hadouken", ["down", "right", "punch"])
combo_detector.register_combo("shoryuken", ["right", "down", "right", "punch"])
combo_detector.combo_detected.connect(_on_combo)
func _on_combo(combo_name: String) -> void:
match combo_name:
"hadouken":
spawn_fireball()
"shoryuken":
perform_uppercut()Touch Input
Handle touch for mobile:
func _input(event: InputEvent) -> void:
if event is InputEventScreenTouch:
if event.pressed:
_on_touch_start(event.position, event.index)
else:
_on_touch_end(event.position, event.index)
elif event is InputEventScreenDrag:
_on_touch_drag(event.position, event.relative, event.index)
# Virtual joystick example
var _touch_origin: Vector2
var _touch_current: Vector2
const JOYSTICK_RADIUS := 100.0
func _on_touch_start(pos: Vector2, _index: int) -> void:
_touch_origin = pos
_touch_current = pos
func _on_touch_drag(pos: Vector2, _relative: Vector2, _index: int) -> void:
_touch_current = pos
func get_virtual_joystick() -> Vector2:
var diff := _touch_current - _touch_origin
if diff.length() > JOYSTICK_RADIUS:
diff = diff.normalized() * JOYSTICK_RADIUS
return diff / JOYSTICK_RADIUS # -1 to 1Best Practices
1. Always use Input Map - Never hardcode key constants 2. Use actions semantically - "jump" not "space_pressed" 3. Buffer important inputs - Especially for action games 4. Support multiple input methods - Keyboard, gamepad, touch 5. Allow rebinding - Players expect customization 6. Consider accessibility - One-handed modes, toggle vs hold 7. Handle focus - Disable input when window loses focus
Common Gotchas
- Input not detected in paused game: Set
process_modetoPROCESS_MODE_ALWAYS - Double input: Check both
_inputand_processaren't handling same action - Gamepad not working: Ensure device is connected before InputMap check
- UI consuming input: Use
_unhandled_inputfor gameplay - Mouse position wrong: Use
get_global_mouse_position()for world space
Object Pooling Patterns
Complete guide to implementing object pools in Godot 4.x for performance optimization.
When to Use Object Pooling
Use pooling when:
- Frequently spawning/despawning objects (bullets, particles, enemies)
- Instantiation causes noticeable stuttering
- Objects have expensive initialization
- Same object types are reused throughout gameplay
Don't use pooling when:
- Objects are spawned rarely
- Each instance needs unique setup
- Memory is more constrained than CPU
- Prototype/early development (premature optimization)
Pattern 1: Simple Pool
Basic pool for a single object type:
class_name SimplePool
extends Node
@export var pooled_scene: PackedScene
@export var initial_size: int = 20
@export var max_size: int = 100
var _available: Array[Node] = []
var _in_use: Array[Node] = []
func _ready() -> void:
_warm_pool()
func _warm_pool() -> void:
for i in initial_size:
var obj := _create_instance()
_available.append(obj)
func _create_instance() -> Node:
var obj := pooled_scene.instantiate()
obj.process_mode = Node.PROCESS_MODE_DISABLED
add_child(obj)
# Connect to auto-release if object has a "finished" signal
if obj.has_signal("finished"):
obj.finished.connect(_on_object_finished.bind(obj))
return obj
func acquire() -> Node:
var obj: Node
if _available.is_empty():
if _in_use.size() >= max_size:
push_warning("Pool exhausted: %s" % pooled_scene.resource_path)
return null
obj = _create_instance()
else:
obj = _available.pop_back()
obj.process_mode = Node.PROCESS_MODE_INHERIT
obj.show()
_in_use.append(obj)
# Call reset if available
if obj.has_method("reset"):
obj.reset()
return obj
func release(obj: Node) -> void:
if obj not in _in_use:
push_warning("Releasing object not from this pool")
return
_in_use.erase(obj)
obj.process_mode = Node.PROCESS_MODE_DISABLED
obj.hide()
_available.append(obj)
func _on_object_finished(obj: Node) -> void:
release(obj)
func get_stats() -> Dictionary:
return {
"available": _available.size(),
"in_use": _in_use.size(),
"total": _available.size() + _in_use.size()
}Pattern 2: Pool Manager (Autoload)
Central manager for multiple pool types:
# pool_manager.gd - Autoload singleton
class_name PoolManager
extends Node
var _pools: Dictionary = {} # scene_path -> SimplePool
func register_pool(scene: PackedScene, initial_size: int = 20, max_size: int = 100) -> void:
var path := scene.resource_path
if _pools.has(path):
push_warning("Pool already registered: %s" % path)
return
var pool := SimplePool.new()
pool.pooled_scene = scene
pool.initial_size = initial_size
pool.max_size = max_size
pool.name = path.get_file().get_basename() + "_Pool"
add_child(pool)
_pools[path] = pool
func acquire(scene: PackedScene) -> Node:
var path := scene.resource_path
if not _pools.has(path):
push_error("Pool not registered: %s" % path)
return null
return _pools[path].acquire()
func release(scene: PackedScene, obj: Node) -> void:
var path := scene.resource_path
if not _pools.has(path):
push_error("Pool not registered: %s" % path)
return
_pools[path].release(obj)
func get_pool(scene: PackedScene) -> SimplePool:
return _pools.get(scene.resource_path)
func clear_all() -> void:
for pool in _pools.values():
pool.queue_free()
_pools.clear()Usage:
# In game initialization
const BULLET_SCENE := preload("res://scenes/bullet.tscn")
const ENEMY_SCENE := preload("res://scenes/enemy.tscn")
func _ready() -> void:
PoolManager.register_pool(BULLET_SCENE, 50, 200)
PoolManager.register_pool(ENEMY_SCENE, 10, 50)
# When spawning
func fire_bullet(position: Vector2, direction: Vector2) -> void:
var bullet := PoolManager.acquire(BULLET_SCENE) as Bullet
if bullet:
bullet.global_position = position
bullet.direction = direction
bullet.activate()
# Bullet auto-releases via "finished" signal when it hits something or expiresPattern 3: Poolable Interface
Standard interface for pooled objects:
# poolable.gd - Interface that pooled objects should implement
class_name Poolable
extends Node
signal finished # Emitted when object should return to pool
var _pool_origin: SimplePool # Reference to owning pool
## Called when acquired from pool. Reset state here.
func reset() -> void:
pass
## Called when object should return to pool.
func finish() -> void:
finished.emit()# bullet.gd - Example poolable object
class_name Bullet
extends Poolable
@export var speed: float = 500.0
@export var lifetime: float = 2.0
var direction: Vector2 = Vector2.RIGHT
var _timer: float = 0.0
func reset() -> void:
_timer = 0.0
direction = Vector2.RIGHT
$CollisionShape2D.disabled = false
func activate() -> void:
show()
set_physics_process(true)
func _physics_process(delta: float) -> void:
position += direction * speed * delta
_timer += delta
if _timer >= lifetime:
_deactivate()
func _on_body_entered(_body: Node2D) -> void:
# Hit something
_deactivate()
func _deactivate() -> void:
$CollisionShape2D.disabled = true
hide()
set_physics_process(false)
finish() # Signal pool to reclaim this objectPattern 4: Component-Based Pool
Pool that manages components rather than full scenes:
class_name ComponentPool
extends Node
var _available: Array[Node] = []
var _component_script: GDScript
func _init(script: GDScript, initial_size: int = 10) -> void:
_component_script = script
for i in initial_size:
_available.append(script.new())
func acquire() -> Node:
if _available.is_empty():
return _component_script.new()
return _available.pop_back()
func release(component: Node) -> void:
if component.has_method("reset"):
component.reset()
_available.append(component)Pool Sizing Strategies
class_name AdaptivePool
extends SimplePool
@export var growth_rate: float = 1.5 # Grow by 50%
@export var shrink_threshold: float = 0.25 # Shrink if <25% used
@export var shrink_delay: float = 30.0 # Seconds before shrinking
var _shrink_timer: float = 0.0
var _peak_usage: int = 0
func _process(delta: float) -> void:
_track_usage(delta)
func _track_usage(delta: float) -> void:
var current_usage := _in_use.size()
_peak_usage = max(_peak_usage, current_usage)
var total := _available.size() + _in_use.size()
var usage_ratio := float(current_usage) / total if total > 0 else 0.0
if usage_ratio < shrink_threshold:
_shrink_timer += delta
if _shrink_timer >= shrink_delay:
_shrink_pool()
_shrink_timer = 0.0
else:
_shrink_timer = 0.0
func _grow_pool() -> void:
var current_size := _available.size() + _in_use.size()
var new_objects := int(current_size * (growth_rate - 1.0))
new_objects = max(1, new_objects)
for i in new_objects:
if _available.size() + _in_use.size() >= max_size:
break
_available.append(_create_instance())
func _shrink_pool() -> void:
var target_size := int(_peak_usage * 1.5) # Keep 50% buffer
target_size = max(initial_size, target_size)
while _available.size() + _in_use.size() > target_size and not _available.is_empty():
var obj := _available.pop_back()
obj.queue_free()
_peak_usage = _in_use.size() # Reset peakIntegration with Scene Tree
Pooled objects often need to be children of specific nodes:
# Spawn pooled object as child of another node
func spawn_effect(parent: Node, position: Vector2) -> void:
var effect := PoolManager.acquire(EFFECT_SCENE)
if effect:
# Reparent to desired location
effect.get_parent().remove_child(effect)
parent.add_child(effect)
effect.global_position = position
effect.play()
# Alternative: Pool keeps objects, they draw at world positions
func spawn_bullet(world_pos: Vector2) -> void:
var bullet := PoolManager.acquire(BULLET_SCENE)
if bullet:
bullet.global_position = world_pos
# Bullet stays child of pool but renders at world positionBest Practices
1. Warm pools at load time - Pre-instantiate during loading screens 2. Use signals for auto-release - Objects signal when they're done 3. Reset completely - Clear all state in reset() to avoid bugs 4. Size appropriately - Profile to find right initial/max sizes 5. Handle exhaustion gracefully - Log warnings, don't crash 6. Clear on scene change - Release all objects when changing levels 7. Disable processing - Pooled objects shouldn't run _process when inactive
Common Gotchas
- Transform not reset: Always reset position, rotation, scale in
reset() - Signals still connected: Disconnect custom signals in
reset()orfinish() - Physics still active: Disable collision shapes when pooled
- Timers still running: Stop or reset any Timer nodes
- Animation state: Reset AnimationPlayer to initial state
Save/Load System Patterns
Complete guide to implementing save systems in Godot 4.x.
Save System Approaches
| Approach | Best For | Pros | Cons |
|---|---|---|---|
| Custom Resource | Structured game data | Type-safe, fast, editor preview | Binary format, version migration |
| JSON | Config, web games | Human-readable, portable | No type safety, slower |
| ConfigFile | Settings, simple saves | Built-in, INI format | Limited structure |
| Binary | Large datasets | Compact, fast | Not human-readable |
Pattern 1: Resource-Based Save System
Type-safe saves using custom Resources:
# save_data.gd - Custom resource for save data
class_name SaveData
extends Resource
@export var version: int = 1
@export var timestamp: int = 0
# Player data
@export var player_position: Vector2
@export var player_health: int = 100
@export var player_max_health: int = 100
# Inventory
@export var inventory: Array[String] = []
@export var equipped_weapon: String = ""
@export var gold: int = 0
# Progress
@export var current_level: String = "res://levels/level_01.tscn"
@export var unlocked_levels: Array[String] = []
@export var completed_quests: Array[String] = []
# Settings (could be separate resource)
@export var music_volume: float = 1.0
@export var sfx_volume: float = 1.0
static func create_new() -> SaveData:
var data := SaveData.new()
data.timestamp = int(Time.get_unix_time_from_system())
return data# save_manager.gd - Autoload for save/load operations
class_name SaveManager
extends Node
signal save_completed(slot: int)
signal load_completed(slot: int, data: SaveData)
signal save_failed(slot: int, error: String)
const SAVE_DIR := "user://saves/"
const SAVE_EXTENSION := ".tres" # Or ".res" for binary
var current_save: SaveData
func _ready() -> void:
_ensure_save_directory()
func _ensure_save_directory() -> void:
DirAccess.make_dir_recursive_absolute(SAVE_DIR)
func get_save_path(slot: int) -> String:
return SAVE_DIR + "save_%02d%s" % [slot, SAVE_EXTENSION]
func save_exists(slot: int) -> bool:
return FileAccess.file_exists(get_save_path(slot))
func save_game(slot: int) -> Error:
if not current_save:
current_save = SaveData.create_new()
current_save.timestamp = int(Time.get_unix_time_from_system())
_collect_save_data()
var path := get_save_path(slot)
var error := ResourceSaver.save(current_save, path)
if error == OK:
save_completed.emit(slot)
else:
save_failed.emit(slot, error_string(error))
return error
func load_game(slot: int) -> SaveData:
var path := get_save_path(slot)
if not FileAccess.file_exists(path):
push_error("Save file not found: %s" % path)
return null
var loaded := ResourceLoader.load(path) as SaveData
if not loaded:
push_error("Failed to load save: %s" % path)
return null
current_save = loaded
_apply_save_data()
load_completed.emit(slot, current_save)
return current_save
func delete_save(slot: int) -> Error:
var path := get_save_path(slot)
if FileAccess.file_exists(path):
return DirAccess.remove_absolute(path)
return OK
func get_save_info(slot: int) -> Dictionary:
var path := get_save_path(slot)
if not FileAccess.file_exists(path):
return {}
var data := ResourceLoader.load(path) as SaveData
if not data:
return {}
return {
"slot": slot,
"timestamp": data.timestamp,
"level": data.current_level,
"playtime": data.timestamp # Could track actual playtime
}
func get_all_saves() -> Array[Dictionary]:
var saves: Array[Dictionary] = []
for slot in range(1, 10): # Slots 1-9
var info := get_save_info(slot)
if not info.is_empty():
saves.append(info)
return saves
# Override these to customize what gets saved/loaded
func _collect_save_data() -> void:
# Collect data from game state
var player := get_tree().get_first_node_in_group("player") as Player
if player:
current_save.player_position = player.global_position
current_save.player_health = player.health
# Collect from other systems
if has_node("/root/Inventory"):
current_save.inventory = get_node("/root/Inventory").get_items()
func _apply_save_data() -> void:
# Apply saved data to game state
# Usually called after loading the saved level
passPattern 2: JSON Save System
Human-readable saves with JSON:
# json_save_manager.gd
class_name JsonSaveManager
extends Node
const SAVE_DIR := "user://saves/"
func save_to_json(slot: int, data: Dictionary) -> Error:
var path := SAVE_DIR + "save_%02d.json" % slot
var json_string := JSON.stringify(data, "\t")
var file := FileAccess.open(path, FileAccess.WRITE)
if not file:
return FileAccess.get_open_error()
file.store_string(json_string)
file.close()
return OK
func load_from_json(slot: int) -> Dictionary:
var path := SAVE_DIR + "save_%02d.json" % slot
if not FileAccess.file_exists(path):
return {}
var file := FileAccess.open(path, FileAccess.READ)
if not file:
return {}
var json_string := file.get_as_text()
file.close()
var json := JSON.new()
var error := json.parse(json_string)
if error != OK:
push_error("JSON parse error: %s" % json.get_error_message())
return {}
return json.get_data()
# Serialize game state to dictionary
func collect_game_state() -> Dictionary:
var player := get_tree().get_first_node_in_group("player")
return {
"version": 1,
"timestamp": int(Time.get_unix_time_from_system()),
"player": {
"position": {"x": player.position.x, "y": player.position.y},
"health": player.health,
"inventory": player.inventory.duplicate()
},
"level": get_tree().current_scene.scene_file_path,
"enemies": _serialize_enemies(),
"pickups": _serialize_pickups()
}
func _serialize_enemies() -> Array:
var enemies := []
for enemy in get_tree().get_nodes_in_group("enemies"):
enemies.append({
"type": enemy.enemy_type,
"position": {"x": enemy.position.x, "y": enemy.position.y},
"health": enemy.health
})
return enemies
func _serialize_pickups() -> Array:
var pickups := []
for pickup in get_tree().get_nodes_in_group("pickups"):
pickups.append({
"id": pickup.pickup_id,
"collected": pickup.is_collected
})
return pickupsPattern 3: Node-Based Serialization
Save/load individual nodes using groups:
# saveable.gd - Interface for saveable nodes
class_name Saveable
extends Node
## Unique ID for this saveable object
@export var save_id: String
## Return dictionary of data to save
func get_save_data() -> Dictionary:
return {}
## Restore state from saved data
func load_save_data(data: Dictionary) -> void:
pass# Example saveable implementations
# saveable_chest.gd
class_name SaveableChest
extends Saveable
var is_opened: bool = false
var contents: Array[String] = []
func get_save_data() -> Dictionary:
return {
"is_opened": is_opened,
"contents": contents.duplicate()
}
func load_save_data(data: Dictionary) -> void:
is_opened = data.get("is_opened", false)
contents = data.get("contents", [])
if is_opened:
$AnimatedSprite2D.play("opened")# scene_save_manager.gd
class_name SceneSaveManager
extends Node
func collect_scene_data() -> Dictionary:
var data := {}
for saveable in get_tree().get_nodes_in_group("saveable"):
if saveable is Saveable and not saveable.save_id.is_empty():
data[saveable.save_id] = saveable.get_save_data()
return data
func apply_scene_data(data: Dictionary) -> void:
for saveable in get_tree().get_nodes_in_group("saveable"):
if saveable is Saveable and data.has(saveable.save_id):
saveable.load_save_data(data[saveable.save_id])Save Data Versioning
Handle save format changes between game versions:
class_name SaveMigrator
extends RefCounted
const CURRENT_VERSION := 3
static func migrate(data: SaveData) -> SaveData:
var version := data.version
while version < CURRENT_VERSION:
match version:
1:
data = _migrate_v1_to_v2(data)
2:
data = _migrate_v2_to_v3(data)
version += 1
data.version = CURRENT_VERSION
return data
static func _migrate_v1_to_v2(data: SaveData) -> SaveData:
# v1 -> v2: Split health into current and max
if data.player_max_health == 0:
data.player_max_health = 100
return data
static func _migrate_v2_to_v3(data: SaveData) -> SaveData:
# v2 -> v3: Rename level paths
data.current_level = data.current_level.replace("levels/", "worlds/")
return dataAutosave System
class_name AutosaveManager
extends Node
signal autosave_started
signal autosave_completed
@export var autosave_interval: float = 300.0 # 5 minutes
@export var autosave_slot: int = 0 # Slot 0 for autosave
var _timer: float = 0.0
var _enabled: bool = true
func _process(delta: float) -> void:
if not _enabled:
return
_timer += delta
if _timer >= autosave_interval:
_timer = 0.0
autosave()
func autosave() -> void:
autosave_started.emit()
# Don't autosave during certain states
if _is_safe_to_save():
SaveManager.save_game(autosave_slot)
autosave_completed.emit()
func _is_safe_to_save() -> bool:
# Don't save during:
# - Combat
# - Cutscenes
# - Menu screens
# - Loading
return not get_tree().paused
func pause_autosave() -> void:
_enabled = false
func resume_autosave() -> void:
_enabled = true
_timer = 0.0Save File Security
Basic encryption for save files:
const SAVE_KEY := "your-game-secret-key" # Store securely
func save_encrypted(slot: int, data: Dictionary) -> Error:
var json := JSON.stringify(data)
var encrypted := json.to_utf8_buffer()
var file := FileAccess.open_encrypted_with_pass(
get_save_path(slot),
FileAccess.WRITE,
SAVE_KEY
)
if not file:
return FileAccess.get_open_error()
file.store_buffer(encrypted)
file.close()
return OK
func load_encrypted(slot: int) -> Dictionary:
var file := FileAccess.open_encrypted_with_pass(
get_save_path(slot),
FileAccess.READ,
SAVE_KEY
)
if not file:
return {}
var buffer := file.get_buffer(file.get_length())
file.close()
var json_string := buffer.get_string_from_utf8()
var json := JSON.new()
if json.parse(json_string) != OK:
return {}
return json.get_data()Best Practices
1. Use Resources for structured data - Type safety and editor support 2. Version your saves - Always include version number for migration 3. Validate on load - Check for corrupt or tampered data 4. Separate settings from progress - Different update frequencies 5. Use user:// for saves - Platform-independent save location 6. Test save/load early - Add system before too much game logic exists 7. Handle missing data gracefully - Use defaults for missing fields 8. Autosave at safe points - Not during combat or cutscenes
Common Gotchas
- Resource paths change: Store relative paths, not absolute
- Scene structure changes: Use IDs, not node paths
- Circular references: Resources can't have circular refs
- Large saves: Consider splitting into multiple files
- Cloud saves: Account for sync conflicts
State Machine Patterns
Complete guide to implementing state machines in Godot 4.x GDScript.
When to Use State Machines
Use state machines when:
- Entity has distinct behavioral modes (idle, walking, attacking)
- Transitions between modes have specific rules
- State-specific logic would clutter a single script
- You need clear visual representation of behavior flow
Pattern 1: Enum-Based State Machine
Simplest approach for entities with few states:
class_name Player
extends CharacterBody2D
enum State { IDLE, WALK, JUMP, ATTACK, HURT }
signal state_changed(old_state: State, new_state: State)
@export var move_speed: float = 200.0
@export var jump_force: float = -400.0
var current_state: State = State.IDLE:
set(value):
if current_state == value:
return
var old := current_state
_exit_state(current_state)
current_state = value
_enter_state(current_state)
state_changed.emit(old, current_state)
var _velocity: Vector2 = Vector2.ZERO
func _physics_process(delta: float) -> void:
_process_state(delta)
move_and_slide()
func _process_state(delta: float) -> void:
match current_state:
State.IDLE:
_process_idle(delta)
State.WALK:
_process_walk(delta)
State.JUMP:
_process_jump(delta)
State.ATTACK:
_process_attack(delta)
State.HURT:
_process_hurt(delta)
func _enter_state(state: State) -> void:
match state:
State.IDLE:
$AnimationPlayer.play("idle")
State.WALK:
$AnimationPlayer.play("walk")
State.JUMP:
velocity.y = jump_force
$AnimationPlayer.play("jump")
State.ATTACK:
$AnimationPlayer.play("attack")
State.HURT:
$AnimationPlayer.play("hurt")
velocity = Vector2.ZERO
func _exit_state(state: State) -> void:
match state:
State.ATTACK:
$Hitbox.monitoring = false
func _process_idle(_delta: float) -> void:
var input_dir := Input.get_axis("move_left", "move_right")
if input_dir != 0:
current_state = State.WALK
elif Input.is_action_just_pressed("jump") and is_on_floor():
current_state = State.JUMP
elif Input.is_action_just_pressed("attack"):
current_state = State.ATTACK
func _process_walk(delta: float) -> void:
var input_dir := Input.get_axis("move_left", "move_right")
velocity.x = input_dir * move_speed
if input_dir == 0:
current_state = State.IDLE
elif Input.is_action_just_pressed("jump") and is_on_floor():
current_state = State.JUMP
elif Input.is_action_just_pressed("attack"):
current_state = State.ATTACK
func _process_jump(_delta: float) -> void:
velocity.y += get_gravity().y * _delta
if is_on_floor():
current_state = State.IDLE
func _process_attack(_delta: float) -> void:
# Wait for animation to finish
pass
func _process_hurt(_delta: float) -> void:
# Wait for hurt animation
pass
# Called by AnimationPlayer at end of attack/hurt animations
func _on_animation_finished(anim_name: StringName) -> void:
if anim_name == "attack" or anim_name == "hurt":
current_state = State.IDLEPattern 2: State Node Pattern
Each state is a child node. Better for complex states with their own resources:
# state_machine.gd - Parent node managing state transitions
class_name StateMachine
extends Node
signal state_changed(old_state: State, new_state: State)
@export var initial_state: State
var current_state: State
var states: Dictionary = {}
func _ready() -> void:
# Register all State children
for child in get_children():
if child is State:
states[child.name] = child
child.state_machine = self
child.process_mode = Node.PROCESS_MODE_DISABLED
# Start initial state
if initial_state:
current_state = initial_state
current_state.process_mode = Node.PROCESS_MODE_INHERIT
current_state.enter()
func _process(delta: float) -> void:
if current_state:
current_state.update(delta)
func _physics_process(delta: float) -> void:
if current_state:
current_state.physics_update(delta)
func transition_to(state_name: String) -> void:
if not states.has(state_name):
push_error("State not found: " + state_name)
return
var new_state: State = states[state_name]
if current_state == new_state:
return
var old_state := current_state
if current_state:
current_state.exit()
current_state.process_mode = Node.PROCESS_MODE_DISABLED
current_state = new_state
current_state.process_mode = Node.PROCESS_MODE_INHERIT
current_state.enter()
state_changed.emit(old_state, new_state)# state.gd - Base class for all states
class_name State
extends Node
var state_machine: StateMachine
func enter() -> void:
pass
func exit() -> void:
pass
func update(_delta: float) -> void:
pass
func physics_update(_delta: float) -> void:
pass# idle_state.gd - Concrete state implementation
class_name IdleState
extends State
@onready var player: Player = get_parent().get_parent()
func enter() -> void:
player.animation_player.play("idle")
func physics_update(_delta: float) -> void:
var input_dir := Input.get_axis("move_left", "move_right")
if input_dir != 0:
state_machine.transition_to("Walk")
elif Input.is_action_just_pressed("jump") and player.is_on_floor():
state_machine.transition_to("Jump")Scene Tree Structure:
Player (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
├── AnimationPlayer
└── StateMachine (Node)
├── Idle (State)
├── Walk (State)
├── Jump (State)
└── Attack (State)Pattern 3: Pushdown Automaton
Stack-based state machine for states that need to "return" (menus, pause):
class_name PushdownStateMachine
extends Node
signal state_pushed(state: State)
signal state_popped(state: State)
var _stack: Array[State] = []
var current_state: State:
get:
return _stack.back() if not _stack.is_empty() else null
func _process(delta: float) -> void:
if current_state:
current_state.update(delta)
func push_state(state: State) -> void:
if current_state:
current_state.pause()
_stack.push_back(state)
state.enter()
state_pushed.emit(state)
func pop_state() -> State:
if _stack.is_empty():
return null
var popped := _stack.pop_back()
popped.exit()
state_popped.emit(popped)
if current_state:
current_state.resume()
return popped
func replace_state(state: State) -> void:
pop_state()
push_state(state)# Pauseable state base class
class_name PushdownState
extends State
func pause() -> void:
pass
func resume() -> void:
passPattern 4: Hierarchical State Machine
States can have substates (e.g., Grounded contains Idle and Walk):
class_name HierarchicalState
extends State
@export var initial_substate: HierarchicalState
var active_substate: HierarchicalState
var substates: Dictionary = {}
func _ready() -> void:
for child in get_children():
if child is HierarchicalState:
substates[child.name] = child
child.parent_state = self
func enter() -> void:
if initial_substate:
transition_to_substate(initial_substate.name)
func exit() -> void:
if active_substate:
active_substate.exit()
active_substate = null
func update(delta: float) -> void:
if active_substate:
active_substate.update(delta)
func transition_to_substate(state_name: String) -> void:
if not substates.has(state_name):
return
if active_substate:
active_substate.exit()
active_substate = substates[state_name]
active_substate.enter()Example hierarchy:
StateMachine
├── Grounded (HierarchicalState)
│ ├── Idle (State)
│ └── Walk (State)
├── Airborne (HierarchicalState)
│ ├── Jump (State)
│ └── Fall (State)
└── Combat (HierarchicalState)
├── Attack (State)
└── Block (State)Transition Helpers
Utility for defining valid transitions:
class_name StateTransitions
extends RefCounted
var _transitions: Dictionary = {}
func allow(from: int, to: int) -> StateTransitions:
if not _transitions.has(from):
_transitions[from] = []
_transitions[from].append(to)
return self
func allow_from_any(to: int) -> StateTransitions:
# Mark as "any" transition
if not _transitions.has(-1):
_transitions[-1] = []
_transitions[-1].append(to)
return self
func can_transition(from: int, to: int) -> bool:
# Check "any" transitions first
if _transitions.has(-1) and to in _transitions[-1]:
return true
# Check specific transitions
return _transitions.has(from) and to in _transitions[from]# Usage
var transitions := StateTransitions.new()
transitions \
.allow(State.IDLE, State.WALK) \
.allow(State.IDLE, State.JUMP) \
.allow(State.WALK, State.IDLE) \
.allow(State.WALK, State.JUMP) \
.allow(State.JUMP, State.IDLE) \
.allow_from_any(State.HURT) # Can be hurt from any state
func change_state(new_state: State) -> bool:
if not transitions.can_transition(current_state, new_state):
return false
current_state = new_state
return trueBest Practices
1. Keep states focused - One state, one responsibility 2. Use signals for external communication - States shouldn't directly modify other systems 3. Validate transitions - Not all state changes should be allowed 4. Handle animation in enter/exit - Keeps animation logic centralized 5. Consider state history - Sometimes you need to return to previous state 6. Test state transitions - Use unit tests for transition logic
Related skills
FAQ
What is godot-best-practices?
Guide AI agents through Godot 4.x GDScript coding best practices including scene organization, signals, resources, state machines, and performance optimization. This skill should b
When should I use godot-best-practices?
Guide AI agents through Godot 4.x GDScript coding best practices including scene organization, signals, resources, state machines, and performance optimization. This skill should b
Is godot-best-practices safe to install?
Review the Security Audits panel on this page before production use.