Claude Agent Skill · by Wshobson

Godot Gdscript Patterns

Solid patterns for anyone building games in Godot 4 who wants to write maintainable GDScript instead of spaghetti code. Shows you proper state machine architect

Install
Terminal · npx
$npx skills add https://github.com/wshobson/agents --skill godot-gdscript-patterns
Works with Paperclip

How Godot Gdscript Patterns fits into a Paperclip company.

Godot Gdscript Patterns drops into any Paperclip agent that handles this kind of work. Assign it to a specialist inside a pre-configured PaperclipOrg company and the skill becomes available on every heartbeat — no prompt engineering, no tool wiring.

S
SaaS FactoryPaired

Pre-configured AI company — 18 agents, 18 skills, one-time purchase.

$27$59
Explore pack
Source file
SKILL.md564 lines
Expand
---name: godot-gdscript-patternsdescription: 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 ```gdscriptclass_name Playerextends CharacterBody2D # Signalssignal 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: intvar _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.gdclass_name StateMachineextends Node signal state_changed(from_state: StringName, to_state: StringName) @export var initial_state: State var current_state: Statevar 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.gdclass_name Stateextends 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.gdclass_name PlayerIdleextends 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_startedsignal 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.MENUvar 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 eventssignal player_spawned(player: Node2D)signal player_died(player: Node2D)signal player_health_changed(health: int, max_health: int) # Enemy eventssignal enemy_spawned(enemy: Node2D)signal enemy_died(enemy: Node2D, position: Vector2) # Item eventssignal item_collected(item_type: StringName, value: int)signal powerup_activated(powerup_type: StringName) # Level eventssignal 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.gdclass_name WeaponDataextends 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.gdclass_name CharacterStatsextends 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 resourcesclass_name Characterextends 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.gdclass_name ObjectPoolextends 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.gdclass_name PooledBulletextends Area2D signal returned_to_pool @export var speed: float = 500.0@export var lifetime: float = 5.0 var direction: Vector2var _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.gdclass_name HealthComponentextends 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.gdclass_name HitboxComponentextends 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.gdclass_name HurtboxComponentextends 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](references/advanced-patterns.md): - **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