agentleFS
Simone-TarantinoSimone-Tarantino/godot-superpowers/skills/godot-patterns/SKILL.md

godot-patterns

Skill2 starsChanged 6 months agoMITView on GitHub

Godot 4.x reference — GDScript style, scene composition, signals, autoloads, common 2D/3D patterns, Tween, Area input, and Godot 3.x to 4.x migration. Auto-loads when working with .gd or .tscn files.

The file

We split this skill into the rules it gives an agent

4 rules. About 0 are common, 1 is distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

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

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Scene: when there's a hierarchy or designer-tweakable values.

    line 87
  2. Very specific:

    Scene transitions: prefer change_scene_to_packed(packed_scene) over change_scene_to_file(path) (no re-parse).

    line 99
  3. Very specific:

    Use Tween for gameplay (created on demand, garbage-collected).

    line 171
  4. Very specific:

    Use gdscript-migrator agent for systematic migration of legacy code.

    line 250

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “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 Patterns Reference

Source-of-truth: Godot docs and GDScript style guide.

GDScript style essentials

class_name Player
extends CharacterBody2D

## Player controller. Extends docstring at file top is rendered in editor help.

signal health_changed(old: int, new: int)
signal died

enum State { IDLE, RUN, JUMP, FALL }

const MAX_SPEED := 250.0
const JUMP_VELOCITY := -400.0

@export var max_health: int = 100
@export_range(0.0, 1.0) var move_smoothing: float = 0.15
@export var bullet_scene: PackedScene

var current_health: int
var _state: State = State.IDLE

@onready var _sprite: Sprite2D = $Sprite2D
@onready var _hurtbox: Area2D = %Hurtbox  # unique name access

func _ready() -> void:
    current_health = max_health
    _hurtbox.area_entered.connect(_on_hurtbox_area_entered)

func take_damage(amount: int) -> void:
    var prev := current_health
    current_health = maxi(0, current_health - amount)
    health_changed.emit(prev, current_health)
    if current_health == 0:
        died.emit()

func _on_hurtbox_area_entered(area: Area2D) -> void:
    if area.is_in_group("enemy_hitbox"):
        take_damage(area.damage)

Naming

ItemConventionExample
File / foldersnake_caseplayer_controller.gd
class_name, classesPascalCaseclass_name PlayerController
Functions, vars, signalssnake_casetake_damage, health_changed
ConstantsCONSTANT_CASEMAX_SPEED
Enum type / membersPascalCase / CONSTANT_CASEenum State { IDLE, RUN }
Privateleading __internal_state
Node names in scenePascalCasePlayerCamera
Signal namespast tensehealth_changed, enemy_killed

Member order (style guide)

@tool / @icon → class_name / extends → docstring → signals → enums → consts → static vars → @export vars → vars → @onready vars → _init → _ready → other virtual _* → public methods → private _methods → inner classes.

Two blank lines between functions. Tabs for indent. 100-char soft limit. Double quotes by default.

Type hints (mandatory in 4.x)

@export var max_health: int = 100
@onready var _sprite: Sprite2D = $Sprite2D
var velocity := Vector2.ZERO              # inferred type
var enemies: Array[Enemy] = []            # typed array
var stats: Dictionary[String, int] = {}   # typed dict (4.4+)
func heal(amount: int) -> void: ...

Scene composition

  • Composition over inheritance. Build behavior by composing component child nodes (HealthComponent, HitboxComponent, StateMachine) rather than 3+-deep extends chains.
  • Scene when there's a hierarchy or designer-tweakable values; script-only Node when behavior is purely imperative; Resource when it's pure data.
  • One responsibility per scene. Cross-scene wiring via signals or autoloads, never get_node("/root/Main/Player") traversal.
  • Instance with preload(), not load(), when path is static:
const BulletScene := preload("res://scenes/bullet.tscn")

func shoot() -> void:
    var b := BulletScene.instantiate()
    add_child(b)
  • Scene transitions: prefer change_scene_to_packed(packed_scene) over change_scene_to_file(path) (no re-parse). Wrap in autoload SceneManager with fade transition.

Signals

# Declaration (typed args!)
signal health_changed(old: int, new: int)

# Emission
health_changed.emit(prev, current)

# Connection (in code, in _ready, never via editor)
button.pressed.connect(_on_button_pressed)
button.pressed.connect(_attack.bind("heavy"))   # Callable.bind for partial args
button.pressed.connect(_on_pressed, CONNECT_ONE_SHOT)

Decision rule:

PatternUse when
Direct @onready refStable parent-child or unique-name (%) sibling
Local signalOne emitter, N listeners; same scene tree neighborhood
EventBus signalEmitter + listener live in unrelated scenes
Group + call_groupMany-to-many broadcasts; listeners come and go

Autoloads

Register in Project Settings → Globals → Autoload. Use the create-autoload skill to scaffold and register at once. Common ones:

AutoloadRole
EventBusCross-scene signals only
GameStateRun-scoped data: score, level, flags
SceneManagerScene swaps + transitions
AudioManagerBus volumes, SFX/music pool
SaveManagerSerialize/deserialize via Resource
InputManagerAction remap, device tracking
SettingsManagerConfig (resolution, language, accessibility)

Don't autoload anything tied to a specific scene's lifecycle, anything heavy in _process, or anything you'd want multiple instances of. Never queue_free() an autoload.

3D input via Area3D / Area2D

func _ready() -> void:
    var area: Area3D = $Area3D
    area.input_event.connect(_on_area_input_event)
    area.mouse_entered.connect(_on_mouse_entered)

func _on_area_input_event(_camera: Camera3D, event: InputEvent, _pos: Vector3,
        _normal: Vector3, _shape_idx: int) -> void:
    if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
        clicked.emit(self)

For 2D use Area2D.input_event(viewport, event, shape_idx). Set input_pickable = true on the Area.

Tween (gameplay animation)

# Parallel scale + position
var tw := create_tween().set_parallel()
tw.tween_property(self, "scale", Vector3.ONE * 1.2, 0.15)
tw.tween_property(self, "position:y", target_y, 0.25).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)

# Sequence with callback
var seq := create_tween()
seq.tween_property(self, "modulate:a", 0.0, 0.3)
seq.tween_callback(queue_free)

Use Tween for gameplay (created on demand, garbage-collected). Use AnimationPlayer for hand-keyframed animations and AnimationTree for blending state machines.

Resources (custom data classes)

# resources/item.gd
class_name Item
extends Resource

@export var id: StringName
@export var display_name: String
@export var icon: Texture2D
@export var max_stack: int = 1
@export_multiline var description: String

Then @export var starting_item: Item lets designers drag a .tres instance in the Inspector. Caveat: .duplicate() resources mutated at runtime — load() returns the same shared instance.

_process vs _physics_process vs _unhandled_input

CallbackUse for
_process(delta)Visuals, UI, camera follow, non-physics interpolation
_physics_process(delta)Movement, collision queries, physics-tied AI (fixed timestep)
_unhandled_input(event)Input handling (only fires when not consumed by UI)
_input(event)Input that must run even before UI handles it

Disable when idle: set_process(false), set_physics_process(false), or process_mode = PROCESS_MODE_DISABLED.

Pause

get_tree().paused = true

# Per-node behavior:
# PROCESS_MODE_INHERIT (default), PROCESS_MODE_PAUSABLE,
# PROCESS_MODE_WHEN_PAUSED, PROCESS_MODE_ALWAYS, PROCESS_MODE_DISABLED
$PauseMenu.process_mode = Node.PROCESS_MODE_WHEN_PAUSED
$Music.process_mode = Node.PROCESS_MODE_ALWAYS

Project structure (recommended)

project.godot
addons/                # plugins (gut, dialogic, phantom_camera, ...)
autoload/              # singletons (event_bus.gd, game_state.gd)
scenes/                # .tscn grouped by feature
  player/
  enemies/
  ui/
  levels/
scripts/               # reusable scripts not bound to one scene
  components/          # health_component.gd, hurtbox.gd
  state_machine/
resources/             # .tres data (items/, abilities/)
assets/                # raw art/audio (sprites/, audio/, models/)
test/                  # GUT tests
shaders/               # .gdshader files
.gdignore              # in folders Godot should not import

Godot 3.x → 4.x cheatsheet

Wrong (3.x)Right (4.x)
yield(timer, "timeout")await timer.timeout
export var x = 5@export var x: int = 5
onready var n = $N@onready var n: Node = $N
connect("signal", obj, "method")signal_name.connect(callable)
instance()instantiate()
SpatialNode3D
KinematicBody2D + move_and_slide(velocity)CharacterBody2D; set velocity; move_and_slide() no args
PoolStringArrayPackedStringArray
rand_range(a, b)randf_range(a, b)
tool@tool
ReferenceRefCounted
change_scene(path)change_scene_to_file(path) or better change_scene_to_packed(packed)

Use gdscript-migrator agent for systematic migration of legacy code.

Anti-patterns to avoid

  • get_node() / find_child() in _process — cache via @onready.
  • Movement in _process — use _physics_process.
  • Polling Input.is_action_pressed in _process for one-shot actions — use _unhandled_input and event.is_action_pressed.
  • Cross-scene traversal get_node("/root/Main/Player") — use unique names, autoload, or signals.
  • Untyped Array / Dictionary for homogeneous data — use Array[T] / Dictionary[K, V].
  • Dictionary literals for game data — use custom Resource.
  • JSON for full save state — loses Vector2/Color/typed objects. Use Resource save.
  • extends chains 3+ deep — compose with child component nodes.
  • Connecting a signal in both editor and code — pick one (code preferred).
  • Forgetting process_mode on UI/menu — pause kills the menu too.
  • Mutating shared Resource instances — call .duplicate() for per-instance state.
  • Array.append() repeatedly in hot loops — preallocate or reuse.
  • instantiate() mid-frame in a hot loop — pool instead (see genre-pack-* skills).

See also

  • create-component, create-state-machine, create-resource — scaffolding skills
  • setup-collision-layers, setup-input-map, setup-save-system — system setup
  • code-reviewer agent — full GDScript review against this reference

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.