Agents godot-gdscript-patterns
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.
install
source · Clone the upstream repo
git clone https://github.com/wshobson/agents
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/wshobson/agents "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/game-development/skills/godot-gdscript-patterns" ~/.claude/skills/wshobson-agents-godot-gdscript-patterns && rm -rf "$T"
manifest:
plugins/game-development/skills/godot-gdscript-patterns/SKILL.mdsource content
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
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
# 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)
# 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
# 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
# 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)
# 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
# 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
# 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
# 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
# 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)
# 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
# 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
# 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)
# 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)
For advanced Godot patterns, performance tips, and best practices, see references/advanced-patterns.md:
- Pattern 6: Scene Management — Autoload
with async threaded loading (SceneManager
),ResourceLoader.load_threaded_request
check, transition overlay support, and scene swapping withResourceLoader.has_cachedqueue_free - Pattern 7: Save System — Autoload
with AES-encrypted save files (SaveManager
), JSON serialization, and a reusableFileAccess.open_encrypted_with_pass
component node for per-node save/load lifecycleSaveable - Performance Tips — caching
references, avoiding allocations in@onready
, static typing benefits, disabling processing for off-screen nodes_process - Best Practices — Do's and Don'ts covering signals, typing, resources, pooling, and Autoloads