godot-gdscript-patterns

Production-ready GDScript patterns for Godot 4 game architecture, state management, and performance optimization. Covers seven core patterns: state machines, autoload singletons, resource-based data, object pooling, component systems, scene management, and save systems with complete working examples Includes best practices for signal-based decoupling, static typing, caching node references, and avoiding allocations in hot paths Demonstrates performance optimization techniques like object pooling for frequent spawning, disabling processing off-screen, and reusing arrays to minimize garbage collection Provides practical implementations for common game systems: health/damage components, hitbox/hurtbox detection, async scene loading with transitions, and encrypted save file handling

INSTALLATION
npx skills add https://github.com/wshobson/agents --skill godot-gdscript-patterns
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

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 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
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card