agentleFS
mattstrublemattstruble/skills/godot/SKILL.md

godot

Skill2 starsChanged 4 months agoMITView on GitHub

Use when working on any Godot 4.x development — writing or reviewing .gd files, designing scene trees, implementing game systems in GDScript, structuring Godot projects, or asking about nodes, signals, AutoLoads, Resources, or the Godot editor. Also trigger when implementing game patterns (Observer, State, Command, etc.) in a Godot project — this skill takes precedence over game-patterns for Godot-specific implementation. NOT for engine-agnostic pattern theory (see game-patterns). NOT for Love2D (see love2d). NOT for shaders or visual effects (see godot-shader).

12 other projects use a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

8 rules. 0 are common, 0 are distinctive, and 8 are very specific to this project.

  • 0

    Common

    Most of the 11 edited versions we read keep these.

  • 0

    Distinctive

    This author's own choices. Worth a look.

  • 8

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Use preload for scenes known at compile time.

    line 69
  2. Very specific:

    Follow this order in every .gd file:

    line 77
  3. Very specific:

    Methods: don't repeat the class name in arguments — Inventory.add(item) not Inventory.add_item(item).

    line 124
  4. Very specific:

    Always use type hints. Prefer := (type inference) when the right-hand side makes the type obvious.

    line 148
  5. Very specific:

    Use class_name to make custom types available project-wide without preload.

    line 178
  6. Very specific:

    Signal spaghetti: When every node connects to every other node, tracing behavior becomes impossible.

    line 265
  7. Very specific:

    Returning mid-function: Use return only at the start (guard clause) or end of a function.

    line 275
  8. Very specific:

    Using null when a typed default exists: Prefer Vector2.ZERO, "", [], or .new() over null.

    line 277

“Common” is measured: kept by at least 6 in 10 of the 11 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Godot 4.x Development

Godot 4.x (baseline: 4.6) with GDScript. All code examples target GDScript — not C#. This skill is for a hobbyist developer; prefer engine-native features over custom implementations.

References

ReferenceWhen to read it
references/scene-architecture.mdNode selection, scene splitting decisions, large project layout, composition patterns
references/gdscript-patterns.mdSignal bus, state machine, resource config, coroutines, typed arrays, advanced GDScript
references/godot-pattern-mappings.mdFull GDScript implementations of all 23 game patterns with Godot-idiomatic code

Scene Architecture

The scene tree is a composition hierarchy, not an inheritance hierarchy. Each scene is a reusable unit. Prefer many small, focused scenes over one large scene.

Scene vs Node vs Resource — pick one:

  • Scene (.tscn): reusable entity with its own lifecycle — enemies, UI panels, projectiles, pickups. Instantiate with preload().instantiate().
  • Node (inline in parent scene): structural grouping with no independent reuse — a HBoxContainer holding buttons, a Marker2D for spawn points.
  • Resource (.tres / .res): pure data with no scene presence — item stats, weapon configs, level data. Extend Resource and use @export.

Flat over deep. A node tree 4+ levels deep is a design smell. If you're writing get_node("../../Player/Stats"), the scene needs restructuring. Pass references via @export or signals instead.

File naming: snake_case for all directories, scenes, and scripts in Godot 4. Name scripts and scenes after the scene's root node (player.tscn + player.gd).

Split a scene when:

  • It's reused in multiple parent scenes
  • It has its own independent state and lifecycle
  • It exceeds ~20 nodes
  • Different team members (or future you) need to edit it independently

Composition pattern: Give complex entities capabilities via child nodes with scripts, not inheritance chains.

# Player scene tree — each child is a focused capability node
Player (CharacterBody2D)
├── HealthComponent (Node)       # handles HP, death signal
├── HurtboxComponent (Area2D)    # detects incoming damage
├── StateMachine (Node)          # drives behavior states
├── AnimationPlayer
└── Sprite2D

Each component script is self-contained. HealthComponent emits died — the parent doesn't need to know who's listening.

Scene instancing:

const BulletScene: PackedScene = preload("res://scenes/bullet.tscn")

func _shoot() -> void:
    var bullet: Bullet = BulletScene.instantiate()
    bullet.direction = aim_direction
    get_tree().current_scene.add_child(bullet)  # add to scene root, not self

Use preload for scenes known at compile time. Use load only for paths determined at runtime from a fixed, trusted list — never pass user-provided strings directly to load().


GDScript Idioms

Script File Ordering

Follow this order in every .gd file:

# 1. class_name / extends
# 2. ## docstring
# 3. signals
# 4. enums
# 5. constants
# 6. @export vars
# 7. public vars
# 8. private vars (_prefix)
# 9. @onready vars
# 10. _init / _ready / _enter_tree
# 11. _process / _physics_process / _unhandled_input
# 12. signal callbacks (_on_*)
# 13. public methods
# 14. private methods (_prefix)

Signals

Signals decouple emitters from listeners. The emitter knows nothing about who's listening.

# Declaration — always type parameters
signal health_changed(new_health: int, max_health: int)
signal died

# Emit
health_changed.emit(current_health, max_health)

# Connect in _ready — prefer Callable syntax
func _ready() -> void:
    $HealthComponent.health_changed.connect(_on_health_changed)
    $HealthComponent.died.connect(_on_player_died)

func _on_health_changed(new_health: int, max_health: int) -> void:
    $HUD.update_health_bar(new_health, max_health)

Use signals when: a child needs to notify its parent, or two siblings need to communicate without direct references. Use direct calls when: a parent calls a method on a child it owns — that's normal control flow, not coupling.

Signal bus (EventBus AutoLoad): For truly global events (game over, scene transition, achievement unlocked), a signal bus AutoLoad is appropriate. See references/gdscript-patterns.md.

Naming conventions:

  • Signals: past tense (moved, died). Append _started/_finished for process signals (talk_started, talk_finished).
  • Booleans: prefix with is_, can_, or has_ (is_active, can_jump, has_key).
  • Methods: don't repeat the class name in arguments — Inventory.add(item) not Inventory.add_item(item).

Exports and Resources

# Basic exports
@export var speed: float = 200.0
@export var max_health: int = 100
@export var projectile_scene: PackedScene

# Custom Resource for data — define once, reuse everywhere
class_name WeaponData extends Resource
@export var damage: int = 10
@export var fire_rate: float = 0.5
@export var projectile_scene: PackedScene

# Use it
@export var weapon: WeaponData  # assign .tres in editor

Custom Resources are the right tool for item databases, enemy stats, level configs — anything that's data, not behavior.

Static Typing

Always use type hints. Prefer := (type inference) when the right-hand side makes the type obvious. Use explicit annotation when the compiler can't infer.

# Inference — type is obvious from the right-hand side
var speed := 200.0
var direction := Vector2.ZERO
var timer := Timer.new()
# Explicit — compiler can't infer (Variant returns, typed arrays, PackedScene.instantiate())
var text: String = array.pop_back()
var enemy: Enemy = EnemyScene.instantiate()
var enemies: Array[Enemy] = []
var damage_values: Array[int] = [10, 20, 30]
# Bad — no types at all
var speed = 200
func take_damage(amount):
    health -= amount

# Good — typed variable and typed parameters/return
var speed: float = 200.0
func take_damage(amount: int) -> void:
    health -= amount
    health_changed.emit(health, max_health)

Use class_name to make custom types available project-wide without preload.

AutoLoads

AutoLoads are project-wide singletons. Use them sparingly.

Appropriate AutoLoad uses:

  • Signal bus / EventBus (global events)
  • SceneManager (scene transitions)
  • AudioManager (global audio)
  • SaveManager (save/load)

Avoid AutoLoad for: anything a scene can own and pass down. If only two scenes need to share data, pass it via @export or a signal — not a global.

# EventBus.gd — AutoLoad
signal game_over(winner: String)
signal score_changed(new_score: int)

Groups

Groups are lightweight tags for cross-cutting concerns. Avoid get_node path gymnastics.

# Tag nodes in editor or via code
add_to_group("enemies")

# Query from anywhere
func _on_explosion() -> void:
    for enemy in get_tree().get_nodes_in_group("enemies"):
        (enemy as Enemy).stun()

Coroutines and await

# Wait for signal
await get_tree().create_timer(2.0).timeout

# Wait for animation
$AnimationPlayer.play("death")
await $AnimationPlayer.animation_finished

# Sequence with await
func _die() -> void:
    died.emit()
    $AnimationPlayer.play("death")
    await $AnimationPlayer.animation_finished
    queue_free()

Pattern Mapping (Quick Reference)

This skill takes precedence over game-patterns for Godot implementation. When both fire, use the Godot-native approach below. For full GDScript implementations, see references/godot-pattern-mappings.md.

PatternGodot ApproachNote
ObserverSignalsBuilt-in; always prefer over manual observer lists
SingletonAutoLoadRegister in Project Settings → AutoLoad
StateCustom state nodes + StateMachine nodeChild nodes per state; AnimationTree for animation states
CommandCallable + Array bufferStore Callable objects; call .call() to execute
FactoryPackedScene.instantiate()preload the scene; factory node manages spawning
StrategySwappable Resource or script@export var strategy: Resource; duck-typed or typed
DecoratorChild node wrapping behaviorAdd/remove child nodes at runtime
Service LocatorAutoLoad with fallbackAutoLoad provides service; swap implementation at runtime
Event QueueArray + _process drainBuffer events in Array; drain one per frame or in batch
ComponentChild nodes with scriptsEach child = one capability; communicate via signals
Prototyperesource.duplicate()Resource.duplicate(true) for deep copy
FlyweightShared ResourceMultiple nodes share one Resource instance
Object PoolNode pool with visible = falseDisable/re-enable nodes instead of queue_free/instantiate
Double BufferTwo arrays, swap each frameManual; rarely needed — Godot's rendering handles most cases
Game LoopBuilt-in (_process, _physics_process)Override _process(delta) — engine owns the loop
Update Method_process(delta) / _physics_process(delta)Use _physics_process for physics; _process for visuals
Spatial PartitionArea2D/Area3D + physics layersUse collision layers/masks before writing custom grids
Dirty Flag@export setter with dirty boolset(value): _dirty = true; _data = value
Data LocalityTileMapLayer, MultiMeshInstance3DEngine handles; use these nodes for large uniform sets
BytecodeGDScript itself / Expression classExpression.parse() for runtime-evaluated expressions
Subclass SandboxBase script + class_nameExtend base class; override _execute() hook
Type ObjectCustom Resource subclassEach "type" is a .tres file; behavior via exported data

Common Pitfalls

Signal spaghetti: When every node connects to every other node, tracing behavior becomes impossible. Keep signal connections local — a node connects to its own children's signals in _ready. For cross-scene events, route through an EventBus AutoLoad, not direct cross-scene connections.

AutoLoad abuse: AutoLoads are globals. Globals make code hard to test and reason about. Before adding an AutoLoad, ask: can this data live in a scene and be passed down? If yes, do that.

Deep node path coupling: get_node("../../HUD/HealthBar") breaks whenever the tree changes. Instead: pass references via @export, use signals to communicate upward, or use groups for cross-cutting queries.

Skipping static types: Untyped GDScript loses editor autocompletion, misses type errors until runtime, and makes refactoring painful. Type everything — variables, parameters, return types.

Overusing _process: _process runs every frame. For things that only change on events (health bar, score display), connect to a signal instead. For timers, use Timer nodes or get_tree().create_timer(). Reserve _process for things that genuinely need per-frame updates (movement, continuous animation).

Returning mid-function: Use return only at the start (guard clause) or end of a function. Mid-function returns hide control flow and make methods harder to trace.

Using null when a typed default exists: Prefer Vector2.ZERO, "", [], or .new() over null. Null checks proliferate and mask missing initialization. Use null only when the engine API forces it.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.