Back to Architecture & Design Patterns

godot-gdscript-patterns

GodotGDScriptgame developmentpatternsarchitectureoptimizationsignalsstate machines
⭐ 36.8kπŸ“„ MITπŸ•’ 2026-06-16Source β†—

Install this skill

npx skills add wshobson/agents

Works across Claude Code, Cursor, Codex, Copilot & Antigravity

Godot-GDScript-Patterns focuses on formalizing scene organization and system communication within Godot 4.x. It prioritizes decoupled architecture by implementing node-based state machines, centralized event handling via signals, and efficient singleton management through Autoload. By applying these standard structures, you avoid deeply nested dependencies and prevent tightly coupled code that frequently plagues growing game projects. The skill emphasizes the use of class_name, strong typing, and resource-oriented data management to maintain readability across large scene trees. Instead of relying on manual node pathing or brittle global variables, this pattern set guides the creation of modular, maintainable systems that respond predictably to game states, user input, and physics calculations. It is optimized for developers who need scalable patterns for managing complex player mechanics, UI transitions, or global game-state persistence.

When to Use This Skill

  • β€’Building complex character controllers with idle, run, and jump behaviors
  • β€’Creating global managers for score, high scores, and persistent game state
  • β€’Handling menu transitions and pause-screen input across multiple scenes
  • β€’Developing decoupled combat systems where entities emit signals for hits

How to Invoke This Skill

Example prompts that trigger this skill in Claude Code, Cursor, or Antigravity:

  • β€œImplement a state machine for my player character in Godot
  • β€œCreate a singleton game manager in GDScript
  • β€œHow should I structure my scene architecture for Godot 4?
  • β€œRefactor my player logic using GDScript states
  • β€œWhat is the best way to handle global game states in Godot?

Pro Tips

  • πŸ’‘Always use `class_name` for custom nodes to enable type hinting and easier global access.
  • πŸ’‘Prefer signals for decoupling components over direct method calls to reduce dependencies.
  • πŸ’‘Start with a simple state machine (enum based) for character behavior before scaling to complex state chart libraries.

What this skill does

  • β€’Modular State Machine implementation for character behavior
  • β€’Centralized signal-based communication for event-driven systems
  • β€’Autoload/Singleton management for global data and state
  • β€’Inspector-friendly configuration via @export and @export_group
  • β€’Encapsulation of logic within dedicated State nodes
  • β€’Strict type-checking and class registration for safer references

When not to use it

  • βœ•Simple prototypes or game jams where system overhead outweighs project lifespan
  • βœ•Highly procedural tasks that rely on performance-critical C# or GDExtension modules

Example workflow

  1. Identify a complex entity needing state transitions, such as a player.
  2. Define a base State class and child nodes for specific actions like Idle or Attack.
  3. Instantiate a StateMachine node to manage the activation and deactivation of these states.
  4. Configure signals within the StateMachine to monitor transitions and UI updates.
  5. Test state switching using input events to verify logic flow in the debugger.

Prerequisites

  • –Basic familiarity with Godot 4.x scene tree structure
  • –Fundamental knowledge of GDScript syntax and node types

Pitfalls & limitations

  • !Over-engineering state machines for objects with only two states
  • !Creating too many Autoload singletons which can clutter the global namespace
  • !Forgetting to disconnect signals when freeing nodes, leading to memory leaks

FAQ

Why use a state machine instead of a long if-else chain?
State machines encapsulate logic within isolated files, preventing a single script from becoming unmanageable and making it easier to add new behaviors without breaking existing ones.
Is using Autoload for everything a good idea?
No, limit Autoload usage to global managers like score or settings; overuse obscures dependencies and makes unit testing difficult.
How does this differ from generic GDScript?
Generic GDScript allows any approach, whereas this skill enforces structural standards like class-based state separation and clear communication channels.
Are these patterns compatible with Godot 3.x?
The provided code is specific to Godot 4.x features like @export tags and newer GDScript typing; Godot 3.x would require syntax adjustments.

How it compares

While manual scripts often result in rigid spaghetti code, this pattern-based approach establishes formal communication protocols between nodes to ensure long-term project stability.

Source & trust

⭐ 37k starsπŸ“„ MITπŸ•’ Updated 2026-06-16
πŸ“„ Full skill instructions β€” original source: wshobson/agents
# 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)


### Pattern 6: Scene Management

# scene_manager.gd (Autoload)
extends Node

signal scene_loading_started(scene_path: String)
signal scene_loading_progress(progress: float)
signal scene_loaded(scene: Node)
signal transition_started
signal transition_finished

@export var transition_scene: PackedScene
@export var loading_scene: PackedScene

var _current_scene: Node
var _transition: CanvasLayer
var _loader: ResourceLoader

func _ready() -> void:
_current_scene = get_tree().current_scene

if transition_scene:
_transition = transition_scene.instantiate()
add_child(_transition)
_transition.visible = false

func change_scene(scene_path: String, with_transition: bool = true) -> void:
if with_transition:
await _play_transition_out()

_load_scene(scene_path)

func change_scene_packed(scene: PackedScene, with_transition: bool = true) -> void:
if with_transition:
await _play_transition_out()

_swap_scene(scene.instantiate())

func _load_scene(path: String) -> void:
scene_loading_started.emit(path)

# Check if already loaded
if ResourceLoader.has_cached(path):
var scene := load(path) as PackedScene
_swap_scene(scene.instantiate())
return

# Async loading
ResourceLoader.load_threaded_request(path)

while true:
var progress := []
var status := ResourceLoader.load_threaded_get_status(path, progress)

match status:
ResourceLoader.THREAD_LOAD_IN_PROGRESS:
scene_loading_progress.emit(progress[0])
await get_tree().process_frame
ResourceLoader.THREAD_LOAD_LOADED:
var scene := ResourceLoader.load_threaded_get(path) as PackedScene
_swap_scene(scene.instantiate())
return
_:
push_error("Failed to load scene: %s" % path)
return

func _swap_scene(new_scene: Node) -> void:
if _current_scene:
_current_scene.queue_free()

_current_scene = new_scene
get_tree().root.add_child(_current_scene)
get_tree().current_scene = _current_scene

scene_loaded.emit(_current_scene)
await _play_transition_in()

func _play_transition_out() -> void:
if not _transition:
return

transition_started.emit()
_transition.visible = true

if _transition.has_method("transition_out"):
await _transition.transition_out()
else:
await get_tree().create_timer(0.3).timeout

func _play_transition_in() -> void:
if not _transition:
transition_finished.emit()
return

if _transition.has_method("transition_in"):
await _transition.transition_in()
else:
await get_tree().create_timer(0.3).timeout

_transition.visible = false
transition_finished.emit()


### Pattern 7: Save System

# save_manager.gd (Autoload)
extends Node

const SAVE_PATH := "user://savegame.save"
const ENCRYPTION_KEY := "your_secret_key_here"

signal save_completed
signal load_completed
signal save_error(message: String)

func save_game(data: Dictionary) -> void:
var file := FileAccess.open_encrypted_with_pass(
SAVE_PATH,
FileAccess.WRITE,
ENCRYPTION_KEY
)

if file == null:
save_error.emit("Could not open save file")
return

var json := JSON.stringify(data)
file.store_string(json)
file.close()

save_completed.emit()

func load_game() -> Dictionary:
if not FileAccess.file_exists(SAVE_PATH):
return {}

var file := FileAccess.open_encrypted_with_pass(
SAVE_PATH,
FileAccess.READ,
ENCRYPTION_KEY
)

if file == null:
save_error.emit("Could not open save file")
return {}

var json := file.get_as_text()
file.close()

var parsed := JSON.parse_string(json)
if parsed == null:
save_error.emit("Could not parse save data")
return {}

load_completed.emit()
return parsed

func delete_save() -> void:
if FileAccess.file_exists(SAVE_PATH):
DirAccess.remove_absolute(SAVE_PATH)

func has_save() -> bool:
return FileAccess.file_exists(SAVE_PATH)


# saveable.gd (Attach to saveable nodes)
class_name Saveable
extends Node

@export var save_id: String

func _ready() -> void:
if save_id.is_empty():
save_id = str(get_path())

func get_save_data() -> Dictionary:
var parent := get_parent()
var data := {"id": save_id}

if parent is Node2D:
data["position"] = {"x": parent.position.x, "y": parent.position.y}

if parent.has_method("get_custom_save_data"):
data.merge(parent.get_custom_save_data())

return data

func load_save_data(data: Dictionary) -> void:
var parent := get_parent()

if data.has("position") and parent is Node2D:
parent.position = Vector2(data.position.x, data.position.y)

if parent.has_method("load_custom_save_data"):
parent.load_custom_save_data(data)


## Performance Tips

# 1. Cache node references
@onready var sprite := $Sprite2D # Good
# $Sprite2D in _process() # Bad - repeated lookup

# 2. Use object pooling for frequent spawning
# See Pattern 4

# 3. Avoid allocations in hot paths
var _reusable_array: Array = []

func _process(_delta: float) -> void:
_reusable_array.clear() # Reuse instead of creating new

# 4. Use static typing
func calculate(value: float) -> float: # Good
return value * 2.0

# 5. Disable processing when not needed
func _on_off_screen() -> void:
set_process(false)
set_physics_process(false)


## Best Practices

### Do's

- **Use signals for decoupling** - Avoid direct references
- **Type everything** - Static typing catches errors
- **Use resources for data** - Separate data from logic
- **Pool frequently spawned objects** - Avoid GC hitches
- **Use Autoloads sparingly** - Only for truly global systems

### Don'ts

- **Don't use get_node() in loops** - Cache references
- **Don't couple scenes tightly** - Use signals
- **Don't put logic in resources** - Keep them data-only
- **Don't ignore the Profiler** - Monitor performance
- **Don't fight the scene tree** - Work with Godot's design

## Resources

- [Godot Documentation](https://docs.godotengine.org/en/stable/)
- [GDQuest Tutorials](https://www.gdquest.com/)
- [Godot Recipes](https://kidscancode.org/godot_recipes/)

How to Use This Skill Unit

Option A: Project-Specific (Recommended)

  1. Click "Download" above
  2. In your project, create the directory: .agent/skills/godot-gdscript-patterns/
  3. Save the file as SKILL.md
  4. The agent will automatically discover the skill based on its description.

Option B: Global Installation (All Agents)

Save the file to these locations to make it available across all projects:

  • Claude Code: ~/.claude/skills/wshobson/agents/godot-gdscript-patterns/SKILL.md
  • Cursor: ~/.cursor/skills/wshobson/agents/godot-gdscript-patterns/SKILL.md
  • Antigravity: ~/.gemini/antigravity/skills/wshobson/agents/godot-gdscript-patterns/SKILL.md

πŸš€ Install with CLI:
npx skills add wshobson/agents

Read the Master Guide: Mastering Agent Skills β†’

Recommended Rules

View more rules β†’

Recommended Workflows

View more workflows β†’

Recommended MCP Servers

View more MCP servers β†’

Take It Further

Maximize your productivity with these powerful resources

πŸ“‹

Define Your Standards

Set up coding standards to ensure this workflow produces consistent, high-quality results.

Browse Rules Library
πŸ“–

Master Workflows

Learn how to create custom workflows, use Turbo Mode, and build your automation library.

Complete Guide

How to use this Skill in Claude Code & Cursor

For Claude Code (CLI)

To use this skill in Claude Code, copy the rule content into your project's custom instructions or follow our Add-Skill CLI guide. This ensures Claude follows your standards during every code generation.

For Cursor & Windsurf

For Cursor or Windsurf, individual skills are best used in the "Rules for AI" section. This specific unit helps the agent avoid architecture & design patterns issues, leading to cleaner, more efficient code.

Why the skill format matters: the standardized Agent Skills format lets your AI agent load detailed instructions only when they are relevant, keeping your prompt clean while improving results.

Source & attribution

This skill is categorized under Architecture & Design Patterns and is published by W. Shobson, maintained in wshobson/agents.

← Browse All Agent Skills
Sponsored AI assistant. Recommendations may be paid.