godot-gdscript-patterns · diff
git:20260326.2d0b705 to git:20260522.be57c0b
2 added, 488 removed. Audit A to A.
---
name: godot-gdscript-patterns
description: Master Godot 4 GDScript patterns including signals, scenes, state machines, and optimization. Use when building Godot games, implementing game systems, or learning GDScript best practices.
---
# Godot GDScript Patterns
Production patterns for Godot 4.x game development with GDScript, covering architecture, signals, scenes, and optimization.
## When to Use This Skill
- Building games with Godot 4
- Implementing game systems in GDScript
- Designing scene architecture
- Managing game state
- Optimizing GDScript performance
- Learning Godot best practices
## Core Concepts
### 1. Godot Architecture
```
Node: Base building block
├── Scene: Reusable node tree (saved as .tscn)
├── Resource: Data container (saved as .tres)
├── Signal: Event communication
└── Group: Node categorization
```
### 2. GDScript Basics
```gdscript
class_name Player
extends CharacterBody2D
# Signals
signal health_changed(new_health: int)
signal died
# Exports (Inspector-editable)
@export var speed: float = 200.0
@export var max_health: int = 100
@export_range(0, 1) var damage_reduction: float = 0.0
@export_group("Combat")
@export var attack_damage: int = 10
@export var attack_cooldown: float = 0.5
# Onready (initialized when ready)
@onready var sprite: Sprite2D = $Sprite2D
@onready var animation: AnimationPlayer = $AnimationPlayer
@onready var hitbox: Area2D = $Hitbox
# Private variables (convention: underscore prefix)
var _health: int
var _can_attack: bool = true
func _ready() -> void:
_health = max_health
func _physics_process(delta: float) -> void:
var direction := Input.get_vector("left", "right", "up", "down")
velocity = direction * speed
move_and_slide()
func take_damage(amount: int) -> void:
var actual_damage := int(amount * (1.0 - damage_reduction))
_health = max(_health - actual_damage, 0)
health_changed.emit(_health)
if _health <= 0:
died.emit()
```
- ## Patterns
-
- ### Pattern 1: State Machine
-
- ```gdscript
- # state_machine.gd
- class_name StateMachine
- extends Node
-
- signal state_changed(from_state: StringName, to_state: StringName)
-
- @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 _unhandled_input(event: InputEvent) -> void:
- if current_state:
- current_state.handle_input(event)
-
- func transition_to(state_name: StringName, msg: Dictionary = {}) -> void:
- if not states.has(state_name):
- push_error("State '%s' not found" % state_name)
- return
-
- var previous_state := current_state
- previous_state.exit()
- previous_state.process_mode = Node.PROCESS_MODE_DISABLED
-
- current_state = states[state_name]
- current_state.process_mode = Node.PROCESS_MODE_INHERIT
- current_state.enter(msg)
-
- state_changed.emit(previous_state.name, current_state.name)
- ```
-
- ```gdscript
- # state.gd
- class_name State
- extends Node
-
- var state_machine: StateMachine
-
- func enter(_msg: Dictionary = {}) -> void:
- pass
-
- func exit() -> void:
- pass
-
- func update(_delta: float) -> void:
- pass
-
- func physics_update(_delta: float) -> void:
- pass
-
- func handle_input(_event: InputEvent) -> void:
- pass
- ```
-
- ```gdscript
- # player_idle.gd
- class_name PlayerIdle
- extends State
-
- @export var player: Player
-
- func enter(_msg: Dictionary = {}) -> void:
- player.animation.play("idle")
-
- func physics_update(_delta: float) -> void:
- var direction := Input.get_vector("left", "right", "up", "down")
-
- if direction != Vector2.ZERO:
- state_machine.transition_to("Move")
-
- func handle_input(event: InputEvent) -> void:
- if event.is_action_pressed("attack"):
- state_machine.transition_to("Attack")
- elif event.is_action_pressed("jump"):
- state_machine.transition_to("Jump")
- ```
-
- ### Pattern 2: Autoload Singletons
-
- ```gdscript
- # game_manager.gd (Add to Project Settings > Autoload)
- extends Node
-
- signal game_started
- signal game_paused(is_paused: bool)
- signal game_over(won: bool)
- signal score_changed(new_score: int)
-
- enum GameState { MENU, PLAYING, PAUSED, GAME_OVER }
-
- var state: GameState = GameState.MENU
- var score: int = 0:
- set(value):
- score = value
- score_changed.emit(score)
-
- var high_score: int = 0
-
- func _ready() -> void:
- process_mode = Node.PROCESS_MODE_ALWAYS
- _load_high_score()
-
- func _input(event: InputEvent) -> void:
- if event.is_action_pressed("pause") and state == GameState.PLAYING:
- toggle_pause()
-
- func start_game() -> void:
- score = 0
- state = GameState.PLAYING
- game_started.emit()
-
- func toggle_pause() -> void:
- var is_paused := state != GameState.PAUSED
-
- if is_paused:
- state = GameState.PAUSED
- get_tree().paused = true
- else:
- state = GameState.PLAYING
- get_tree().paused = false
-
- game_paused.emit(is_paused)
-
- func end_game(won: bool) -> void:
- state = GameState.GAME_OVER
-
- if score > high_score:
- high_score = score
- _save_high_score()
-
- game_over.emit(won)
-
- func add_score(points: int) -> void:
- score += points
-
- func _load_high_score() -> void:
- if FileAccess.file_exists("user://high_score.save"):
- var file := FileAccess.open("user://high_score.save", FileAccess.READ)
- high_score = file.get_32()
-
- func _save_high_score() -> void:
- var file := FileAccess.open("user://high_score.save", FileAccess.WRITE)
- file.store_32(high_score)
- ```
-
- ```gdscript
- # event_bus.gd (Global signal bus)
- extends Node
-
- # Player events
- signal player_spawned(player: Node2D)
- signal player_died(player: Node2D)
- signal player_health_changed(health: int, max_health: int)
-
- # Enemy events
- signal enemy_spawned(enemy: Node2D)
- signal enemy_died(enemy: Node2D, position: Vector2)
-
- # Item events
- signal item_collected(item_type: StringName, value: int)
- signal powerup_activated(powerup_type: StringName)
-
- # Level events
- signal level_started(level_number: int)
- signal level_completed(level_number: int, time: float)
- signal checkpoint_reached(checkpoint_id: int)
- ```
-
- ### Pattern 3: Resource-based Data
-
- ```gdscript
- # weapon_data.gd
- class_name WeaponData
- extends Resource
-
- @export var name: StringName
- @export var damage: int
- @export var attack_speed: float
- @export var range: float
- @export_multiline var description: String
- @export var icon: Texture2D
- @export var projectile_scene: PackedScene
- @export var sound_attack: AudioStream
- ```
-
- ```gdscript
- # character_stats.gd
- class_name CharacterStats
- extends Resource
-
- signal stat_changed(stat_name: StringName, new_value: float)
-
- @export var max_health: float = 100.0
- @export var attack: float = 10.0
- @export var defense: float = 5.0
- @export var speed: float = 200.0
-
- # Runtime values (not saved)
- var _current_health: float
-
- func _init() -> void:
- _current_health = max_health
-
- func get_current_health() -> float:
- return _current_health
-
- func take_damage(amount: float) -> float:
- var actual_damage := maxf(amount - defense, 1.0)
- _current_health = maxf(_current_health - actual_damage, 0.0)
- stat_changed.emit("health", _current_health)
- return actual_damage
-
- func heal(amount: float) -> void:
- _current_health = minf(_current_health + amount, max_health)
- stat_changed.emit("health", _current_health)
-
- func duplicate_for_runtime() -> CharacterStats:
- var copy := duplicate() as CharacterStats
- copy._current_health = copy.max_health
- return copy
- ```
-
- ```gdscript
- # Using resources
- class_name Character
- extends CharacterBody2D
-
- @export var base_stats: CharacterStats
- @export var weapon: WeaponData
-
- var stats: CharacterStats
-
- func _ready() -> void:
- # Create runtime copy to avoid modifying the resource
- stats = base_stats.duplicate_for_runtime()
- stats.stat_changed.connect(_on_stat_changed)
-
- func attack() -> void:
- if weapon:
- print("Attacking with %s for %d damage" % [weapon.name, weapon.damage])
-
- func _on_stat_changed(stat_name: StringName, value: float) -> void:
- if stat_name == "health" and value <= 0:
- die()
- ```
-
- ### Pattern 4: Object Pooling
-
- ```gdscript
- # object_pool.gd
- class_name ObjectPool
- extends Node
-
- @export var pooled_scene: PackedScene
- @export var initial_size: int = 10
- @export var can_grow: bool = true
-
- var _available: Array[Node] = []
- var _in_use: Array[Node] = []
-
- func _ready() -> void:
- _initialize_pool()
-
- func _initialize_pool() -> void:
- for i in initial_size:
- _create_instance()
-
- func _create_instance() -> Node:
- var instance := pooled_scene.instantiate()
- instance.process_mode = Node.PROCESS_MODE_DISABLED
- instance.visible = false
- add_child(instance)
- _available.append(instance)
-
- # Connect return signal if exists
- if instance.has_signal("returned_to_pool"):
- instance.returned_to_pool.connect(_return_to_pool.bind(instance))
-
- return instance
-
- func get_instance() -> Node:
- var instance: Node
-
- if _available.is_empty():
- if can_grow:
- instance = _create_instance()
- _available.erase(instance)
- else:
- push_warning("Pool exhausted and cannot grow")
- return null
- else:
- instance = _available.pop_back()
-
- instance.process_mode = Node.PROCESS_MODE_INHERIT
- instance.visible = true
- _in_use.append(instance)
-
- if instance.has_method("on_spawn"):
- instance.on_spawn()
-
- return instance
-
- func _return_to_pool(instance: Node) -> void:
- if not instance in _in_use:
- return
-
- _in_use.erase(instance)
-
- if instance.has_method("on_despawn"):
- instance.on_despawn()
-
- instance.process_mode = Node.PROCESS_MODE_DISABLED
- instance.visible = false
- _available.append(instance)
-
- func return_all() -> void:
- for instance in _in_use.duplicate():
- _return_to_pool(instance)
- ```
-
- ```gdscript
- # pooled_bullet.gd
- class_name PooledBullet
- extends Area2D
-
- signal returned_to_pool
-
- @export var speed: float = 500.0
- @export var lifetime: float = 5.0
-
- var direction: Vector2
- var _timer: float
-
- func on_spawn() -> void:
- _timer = lifetime
-
- func on_despawn() -> void:
- direction = Vector2.ZERO
-
- func initialize(pos: Vector2, dir: Vector2) -> void:
- global_position = pos
- direction = dir.normalized()
- rotation = direction.angle()
-
- func _physics_process(delta: float) -> void:
- position += direction * speed * delta
-
- _timer -= delta
- if _timer <= 0:
- returned_to_pool.emit()
-
- func _on_body_entered(body: Node2D) -> void:
- if body.has_method("take_damage"):
- body.take_damage(10)
- returned_to_pool.emit()
- ```
-
- ### Pattern 5: Component System
-
- ```gdscript
- # health_component.gd
- class_name HealthComponent
- extends Node
-
- signal health_changed(current: int, maximum: int)
- signal damaged(amount: int, source: Node)
- signal healed(amount: int)
- signal died
-
- @export var max_health: int = 100
- @export var invincibility_time: float = 0.0
-
- var current_health: int:
- set(value):
- var old := current_health
- current_health = clampi(value, 0, max_health)
- if current_health != old:
- health_changed.emit(current_health, max_health)
-
- var _invincible: bool = false
-
- func _ready() -> void:
- current_health = max_health
-
- func take_damage(amount: int, source: Node = null) -> int:
- if _invincible or current_health <= 0:
- return 0
-
- var actual := mini(amount, current_health)
- current_health -= actual
- damaged.emit(actual, source)
-
- if current_health <= 0:
- died.emit()
- elif invincibility_time > 0:
- _start_invincibility()
-
- return actual
-
- func heal(amount: int) -> int:
- var actual := mini(amount, max_health - current_health)
- current_health += actual
- if actual > 0:
- healed.emit(actual)
- return actual
-
- func _start_invincibility() -> void:
- _invincible = true
- await get_tree().create_timer(invincibility_time).timeout
- _invincible = false
- ```
-
- ```gdscript
- # hitbox_component.gd
- class_name HitboxComponent
- extends Area2D
-
- signal hit(hurtbox: HurtboxComponent)
-
- @export var damage: int = 10
- @export var knockback_force: float = 200.0
-
- var owner_node: Node
-
- func _ready() -> void:
- owner_node = get_parent()
- area_entered.connect(_on_area_entered)
-
- func _on_area_entered(area: Area2D) -> void:
- if area is HurtboxComponent:
- var hurtbox := area as HurtboxComponent
- if hurtbox.owner_node != owner_node:
- hit.emit(hurtbox)
- hurtbox.receive_hit(self)
- ```
-
- ```gdscript
- # hurtbox_component.gd
- class_name HurtboxComponent
- extends Area2D
-
- signal hurt(hitbox: HitboxComponent)
-
- @export var health_component: HealthComponent
-
- var owner_node: Node
-
- func _ready() -> void:
- owner_node = get_parent()
-
- func receive_hit(hitbox: HitboxComponent) -> void:
- hurt.emit(hitbox)
-
- if health_component:
- health_component.take_damage(hitbox.damage, hitbox.owner_node)
- ```
+ ## Detailed patterns and worked examples
- For advanced Godot patterns, performance tips, and best practices, see [references/advanced-patterns.md](references/advanced-patterns.md):
+ Detailed pattern documentation lives in `references/details.md`. Read that file when the navigation tier above is insufficient.
- - **Pattern 6: Scene Management** — Autoload `SceneManager` with async threaded loading (`ResourceLoader.load_threaded_request`), `ResourceLoader.has_cached` check, transition overlay support, and scene swapping with `queue_free`
- - **Pattern 7: Save System** — Autoload `SaveManager` with AES-encrypted save files (`FileAccess.open_encrypted_with_pass`), JSON serialization, and a reusable `Saveable` component node for per-node save/load lifecycle
- - **Performance Tips** — caching `@onready` references, avoiding allocations in `_process`, static typing benefits, disabling processing for off-screen nodes
- - **Best Practices** — Do's and Don'ts covering signals, typing, resources, pooling, and Autoloads