
Godot Gdscript Mastery
- 265 installs
- 454 repo stars
- Updated July 28, 2026
- thedivergentai/gd-agentic-skills
Use godot-gdscript-mastery for development tasks
About
godot-gdscript-mastery: A skill for development. This provides functionality for development workflows.
- godot-gdscript-mastery
Godot Gdscript Mastery by the numbers
- 265 all-time installs (skills.sh)
- +16 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #1,448 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/thedivergentai/gd-agentic-skills --skill godot-gdscript-masteryAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 265 |
|---|---|
| repo stars | ★ 454 |
| Last updated | July 28, 2026 |
| Repository | thedivergentai/gd-agentic-skills ↗ |
What it does
Use godot-gdscript-mastery for development tasks
Files
GDScript Mastery
Expert guidance for writing performant, maintainable GDScript following official Godot standards.
Available Scripts
typed_collections_mastery.gd
Expert performance optimization using statically typed Arrays and Dictionaries.
functional_lambda_logic.gd
Advanced list processing using reduce(), all(), and any() with clean lambda syntax.
safe_type_casting.gd
Best practices for using the as operator for crash-proof object identification.
typed_signal_definitions.gd
Enforcing type safety across script boundaries using strictly typed signal arguments.
callable_binding_context.gd
Injecting extra context into signal callbacks using Callable.bind().
unbind_signal_args.gd
Safely discarding unneeded signal arguments using Callable.unbind().
await_sequence_manager.gd
Managing complex asynchronous flows and timers using await without thread-blocking.
array_preallocation_perf.gd
Eliminating memory reallocation lag by pre-sizing large arrays with resize().
static_var_singleton_alt.gd
Using static var for global state management as an alternative to heavy Autoloads.
dictionary_safe_iteration.gd
The correct pattern for erasing dictionary keys while iterating to avoid runtime errors.
NEVER Do in GDScript
- NEVER use `@onready` and `@export` on the same variable — Initialization order will cause
@onreadyto overwrite the Inspector value [1]. - NEVER modify a Dictionary's size while iterating it — Use
dict.keys().duplicate()or iterate a clone to safely erase elements [2, 3]. - NEVER use string-based `connect("signal", ...)` — Always use the Signal object syntax (
button.pressed.connect(...)) for compile-time safety [4]. - NEVER attempt to override non-virtual native engine methods — Overriding
queue_free()orget_class()is unsupported and will be ignored by engine callbacks [5, 6]. - NEVER use dynamic `get_node()` or `$` inside `_process()` — Fetching paths every frame stalls the CPU. Cache and use
@onready[7, 8]. - NEVER use `Parent.method()` calls — Violates "Signal Up, Call Down". Use signals to communicate with parents.
- NEVER use `is` followed by a hard cast — If the type check passes but the object changes, it crashes. Use
asand check for null. - NEVER use `print()` for production debugging — Use
push_error(),push_warning(), or breakpoints to ensure errors are visible in the console/logs. - NEVER pre-load huge resources in `_ready()` — This causes frame stutters. Use
ResourceLoader.load_threaded_request()for async loading. - NEVER use global variables in Autoloads when `static var` is sufficient — Static variables offer better encapsulation and less project pollution [24].
---
Core Directives
1. Strong Typing & Performance
Always use static typing. In Godot 4.x, this enables optimized opcodes by bypassing runtime Variant type-checking.
- Opcode Optimization: When types are known at compile-time, the engine uses faster, typed execution paths.
- Why it's faster: Dynamic variables are internally tracked as 24-byte
Variantstructures [3]. Every operation on a dynamic variable requires the engine to evaluate the underlying type at runtime, incurring significant overhead [2, 4]. - Safe Lines: Confirm optimizations in the script editor; green line numbers in the gutter indicate guaranteed type safety and optimized execution [6, 7].
- Typed Global Methods: Use typed math functions for a performance boost (e.g., use
absf(),ceili(),clampf()instead of the genericabs(),ceil(),clamp()). - Rule: Prefer explicit inference
:=when the type is obvious:var pos := Vector2(10, 10). - Rule: Always specify return types for functions:
func _ready() -> void:.
2. Signal Architecture
- Connect in `_ready()`: Preferably connect signals in code to maintain visibility, rather than just in the editor.
- Typed Signals: Define signals with types:
signal item_collected(item: ItemResource). - Pattern: "Signal Up, Call Down". Children should never call methods on parents; they should emit signals instead.
3. Node Access & Lifecycle Safety
- @onready vs _init():
- Use
_init()ONLY for constructor logic (data initialization, memory allocation). - Use
@onreadyfor node dependencies. Child nodes are NOT available in_init(). - DANGER: Avoid
_init(args)for nodes that will be part of a Scene. It breaksPackedScene.instantiate(). Use@exportfor parameter injection. - Unique Names: Use
%UniqueNamesfor nodes that are critical to the script's logic. - Onready Overrides: Prefer
@onready var sprite = %Sprite2Dover callingget_node()in every function.
4. Callable & Signal (First-Class Citizens)
In Godot 4, Callable and Signal are built-in types. They can be stored in variables and passed as arguments.
- No Strings: Always connect via references:
button.pressed.connect(_on_pressed). - Anonymous Lambdas: Use for quick inline logic:
timer.timeout.connect(func(): print("Time up!")). - Binding Context: Use
Callable.bind()to pass extra arguments to a signal callback:hit.connect(_on_hit.bind("Sword")).
5. Code Structure
Follow the standard Godot script layout: 1. extends 2. class_name 3. signals / enums / constants 4. @export / @onready / properties 5. _init() / _ready() / _process() 6. Public methods 7. Private methods (prefixed with _)
Common "Architect" Patterns
1. Refactoring Checklist: Godot 3.x to 4.x
Essential syntax shifts when porting legacy scripts to GDScript 2.0.
- Annotations: Replace
export,onready,toolwith@export,@onready, and@tool. - Example:
@export_enum("A", "B") var type: intreplaces legacy string hints. - Coroutines: Replace
yield()with theawaitkeyword. - Example:
await get_tree().create_timer(1.0).timeout - Common Pattern:
await get_tree().process_framereplacesyield(get_tree(), "idle_frame"). - Properties: Replace
setgetwith inline property syntax. - Syntax:
var x: int: set(v): x = v; get: return x - Signals: Migrate from string-based connections to first-class Callable references.
- Connection:
btn.pressed.connect(_on_pressed)(deprecated:btn.connect("pressed", ...)). - Emission:
my_signal.emit(args)(deprecated:emit_signal("my_signal", ...)). - Node Renames: Be aware of renamed nodes (e.g.,
KinematicBody3D->CharacterBody3D,Position2D->Marker2D). - Lifecycle Calls: Explicitly call
super()in overridden_ready(),_process(), or_init()if parent logic is required.
2. Static Utility Libraries
Use static members to build lightweight helper libraries that bypass the SceneTree.
- Static Functions: Call via class name without instantiating:
MathUtils.calculate(val). - Restriction: No access to
self,instancevariables, or non-static methods. - Static Variables: Shared globally across the project.
- Restriction: Cannot use
@exportor@onreadyon static variables. - @static_unload: Place at the top of the script to instruct the engine to unload the script when no references remain.
- CRITICAL: Due to a current engine bug, scripts with static variables may not be automatically freed. Manually nullify large static data structures (like Dictionaries or Arrays of Resources) when no longer needed to prevent memory leaks.
3. The "Safe" Dictionary Lookup
Avoid dict["key"] if you aren't 100% sure it exists. Use dict.get("key", default).
4. Scene Unique Nodes
When building complex UI, always toggle "Access as Scene Unique Name" on critical nodes (Labels, Buttons) and access them via %Name.
Reference
- Official Docs:
tutorials/scripting/gdscript/gdscript_styleguide.rst - Official Docs:
tutorials/best_practices/logic_preferences.rst
Related
- Master Skill: godot-master
# skills/gdscript-mastery/code/advanced_lambdas.gd
extends Node
## GDScript Mastery Expert Pattern
## Features Higher-Order Functions and Functional Composition.
func _ready() -> void:
var numbers: Array[int] = [1, 2, 3, 4, 5, 6]
# 1. Lambda Filtering & Mapping
var evens = numbers.filter(func(n): return n % 2 == 0)
var doubled = evens.map(func(n): return n * 2)
# 2. Higher-Order Function (Function returning a Callable)
var multiplier_for_3 = make_multiplier(3)
print(multiplier_for_3.call(10)) # Outputs 30
## Returns a lambda that multiplies input by 'factor'
func make_multiplier(factor: float) -> Callable:
return func(value): return value * factor
## 3. Typed Array Composition
## Experts use typed arrays to ensure the compiler can optimize loops.
func process_entities(entities: Array[Node2D], processor: Callable) -> void:
for entity in entities:
if is_instance_valid(entity):
processor.call(entity)
## EXPERT NOTE:
## Use '@static' for utility functions that don't need instance state.
## This allows the engine to call them without allocating object memory.
static func calculate_distance_sq(a: Vector2, b: Vector2) -> float:
return a.distance_squared_to(b)
# array_preallocation_perf.gd
# Avoiding reallocation spikes by pre-sizing large arrays
extends Node
# EXPERT NOTE: Calling append() 10,000 times triggers hundreds of
# expensive memory reallocations. Always resize() first.
func fast_generate_lattice(size: int) -> PackedVector3Array:
var lattice := PackedVector3Array()
# Pre-allocate memory instantly
lattice.resize(size * size)
for i in size:
for j in size:
# Index-based assignment is significantly faster than append()
lattice[i * size + j] = Vector3(i, 0, j)
return lattice
# await_sequence_manager.gd
# Pausing execution flow using await without blocking threads
extends Node
func play_intro_sequence():
print("Step 1: Fade In")
# Suspend until a signal is received
await get_tree().create_timer(1.0).timeout
print("Step 2: Spawn Player")
# Suspend until next physics frame
await get_tree().physics_frame
print("Step 3: Enable UI")
await _ui_animation_done()
print("Sequence Complete")
func _ui_animation_done():
# Example of a function returning a signal to be awaited
return get_tree().create_timer(0.5).timeout
# callable_binding_context.gd
# Injecting extra data into callbacks using bind()
extends Node
func _ready() -> void:
for i in range(5):
var btn = Button.new()
# When clicked, _on_button_pressed will receive 'i' as an argument
btn.pressed.connect(_on_button_pressed.bind(i))
add_child(btn)
func _on_button_pressed(index: int) -> void:
print("Clicked button number: ", index)
# dictionary_safe_iteration.gd
# Correct pattern for deleting keys during iteration
extends Node
func cleanup_expired_data(data: Dictionary):
# NEVER erase from the dict while iterating it directly.
# Create a copy of keys to iterate instead.
var keys_to_check = data.keys()
for key in keys_to_check:
if _is_expired(data[key]):
data.erase(key) # Safe because we iterate the clone
func _is_expired(_val) -> bool:
return true
# functional_lambda_logic.gd
# Advanced list processing using reduce(), all(), and any()
extends Node
func get_best_target(targets: Array[Node3D]) -> Node3D:
if targets.is_empty(): return null
# 'reduce' implementation for finding the closest target
return targets.reduce(
func(best: Node3D, current: Node3D):
var d1 = global_position.distance_to(best.global_position)
var d2 = global_position.distance_to(current.global_position)
return current if d2 < d1 else best
)
func is_room_clear(entities: Array[Node]) -> bool:
# 'all' returns true if the condition matches every element
return entities.all(func(e): return e.is_in_group("friendly"))
# skills/gdscript-mastery/scripts/performance_analyzer.gd
@tool
extends EditorScript
## Performance Analyzer Expert Pattern
## Detects common GDScript performance anti-patterns.
const PERFORMANCE_KILLERS := {
"get_node": {
"pattern": "get_node\\(.*\\)",
"message": "get_node() in hot path - cache with @onready",
"severity": "HIGH"
},
"$_access": {
"pattern": "\\$\\w+",
"message": "$ node access in loop - cache in @onready var",
"severity": "MEDIUM"
},
"dynamic_dict": {
"pattern": "\\w+\\[\"\\w+\"\\]",
"message": "Direct dict access - use .get(key, default) for safety",
"severity": "MEDIUM"
},
"string_concat": {
"pattern": "\\w+\\s*\\+\\s*\"",
"message": "String concatenation in loop - use Array.join() or format strings",
"severity": "LOW"
}
}
func _run() -> void:
print("=== Performance Analyzer ===")
var issues: Array[Dictionary] = []
_scan_directory("res://", issues)
if issues.is_empty():
print("✓ No obvious performance issues detected!")
else:
_print_issues(issues)
func _scan_directory(path: String, issues: Array[Dictionary]) -> void:
var dir := DirAccess.open(path)
if not dir:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
var full_path := path + file_name
if dir.current_is_dir():
if not file_name.begins_with(".") and file_name != "addons":
_scan_directory(full_path + "/", issues)
elif file_name.ends_with(".gd"):
_analyze_script(full_path, issues)
file_name = dir.get_next()
func _analyze_script(script_path: String, issues: Array[Dictionary]) -> void:
var file := FileAccess.open(script_path, FileAccess.READ)
if not file:
return
var line_number := 0
var in_process_loop := false
while not file.eof_reached():
line_number += 1
var line := file.get_line()
# Track if we're in _process or _physics_process
if "_process(" in line or "_physics_process(" in line:
in_process_loop = true
if in_process_loop:
for killer_name in PERFORMANCE_KILLERS:
var killer: Dictionary = PERFORMANCE_KILLERS[killer_name]
var regex := RegEx.new()
regex.compile(killer["pattern"])
if regex.search(line):
issues.append({
"file": script_path,
"line": line_number,
"severity": killer["severity"],
"message": killer["message"],
"content": line.strip_edges()
})
func _print_issues(issues: Array[Dictionary]) -> void:
# Group by severity
var high: Array[Dictionary] = []
var medium: Array[Dictionary] = []
var low: Array[Dictionary] = []
for issue in issues:
match issue["severity"]:
"HIGH": high.append(issue)
"MEDIUM": medium.append(issue)
"LOW": low.append(issue)
if not high.is_empty():
print("\n🔴 HIGH Severity Issues (%d):" % high.size())
for issue in high:
print(" %s:%d - %s" % [issue["file"], issue["line"], issue["message"]])
if not medium.is_empty():
print("\n🟡 MEDIUM Severity Issues (%d):" % medium.size())
for issue in medium:
print(" %s:%d - %s" % [issue["file"], issue["line"], issue["message"]])
if not low.is_empty():
print("\n🟢 LOW Severity Issues (%d):" % low.size())
## EXPERT NOTE:
## Run this before optimizing performance bottlenecks.
## CRITICAL: get_node() in _process() = 10-100x slower than cached @onready.
## String concatenation in loops = GC pressure, use %s formatting instead.
# safe_type_casting.gd
# Using 'as' for crash-proof object identification
extends Area2D
# EXPERT NOTE: Avoid 'is' followed by a hard cast. Use 'as' and
# check for null to prevent runtime crashes on mismatch.
func _on_body_entered(body: Node2D) -> void:
# Safe cast: returns null if body is not the class/script
var player := body as CharacterBody2D
if player and player.has_method("apply_damage"):
player.call("apply_damage", 10)
else:
# Not a player, ignore safely
pass
# skills/gdscript-mastery/scripts/signal_architecture_validator.gd
@tool
extends EditorScript
## Signal Architecture Validator Expert Pattern
## Enforces "Signal Up, Call Down" pattern - detects parent method calls from children.
func _run() -> void:
print("=== Signal Architecture Validator ===")
var violations: Array[Dictionary] = []
_scan_directory("res://", violations)
if violations.is_empty():
print("✓ Signal architecture follows best practices!")
else:
print("⚠️ Found %d potential violations:" % violations.size())
_print_violations(violations)
func _scan_directory(path: String, violations: Array[Dictionary]) -> void:
var dir := DirAccess.open(path)
if not dir:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
var full_path := path + file_name
if dir.current_is_dir():
if not file_name.begins_with(".") and file_name != "addons":
_scan_directory(full_path + "/", violations)
elif file_name.ends_with(".gd"):
_check_signal_architecture(full_path, violations)
file_name = dir.get_next()
func _check_signal_architecture(script_path: String, violations: Array[Dictionary]) -> void:
var file := FileAccess.open(script_path, FileAccess.READ)
if not file:
return
var content := file.get_as_text()
var lines := content.split("\n")
for i in range(lines.size()):
var line := lines[i]
# Check for get_parent() calls (code smell)
if "get_parent()" in line and not line.strip_edges().begins_with("#"):
violations.append({
"file": script_path,
"line": i + 1,
"type": "get_parent_call",
"severity": "HIGH",
"message": "get_parent() is a code smell - use signals to communicate up",
"content": line.strip_edges()
})
# Check for owner. method calls (potential violation)
if "owner." in line and ("(" in line) and not line.strip_edges().begins_with("#"):
violations.append({
"file": script_path,
"line": i + 1,
"type": "owner_method_call",
"severity": "MEDIUM",
"message": "Calling owner methods - consider using signals instead",
"content": line.strip_edges()
})
# Check for untyped signal definitions
if line.strip_edges().begins_with("signal ") and "(" not in line:
violations.append({
"file": script_path,
"line": i + 1,
"type": "untyped_signal",
"severity": "LOW",
"message": "Signal without typed parameters - add type hints",
"content": line.strip_edges()
})
func _print_violations(violations: Array[Dictionary]) -> void:
var by_severity := {"HIGH": [], "MEDIUM": [], "LOW": []}
for v in violations:
by_severity[v["severity"]].append(v)
for severity in ["HIGH", "MEDIUM", "LOW"]:
var items: Array = by_severity[severity]
if items.is_empty():
continue
var icon := "🔴" if severity == "HIGH" else ("🟡" if severity == "MEDIUM" else "🟢")
print("\n%s %s Severity (%d):" % [icon, severity, items.size()])
for v in items:
print(" %s:%d [%s]" % [v["file"], v["line"], v["type"]])
print(" → %s" % v["message"])
## EXPERT NOTE:
## "Signal Up, Call Down" = Children emit signals, Parents call child methods.
## ANTI-PATTERN: get_parent().some_method() - tight coupling, breaks reusability.
## CORRECT: emit signal, let parent decide how to handle it.
## Example:
## Child: signal health_depleted()
## Parent: child.health_depleted.connect(_on_child_died)
# static_var_singleton_alt.gd
# Using static variables for global state without Autoloads
extends Node
class_name GlobalState
# EXPERT NOTE: static var is shared across all scripts.
# Accessible via GlobalState.score from anywhere.
static var score: int = 0
static var unlocked_levels: Array[int] = [1]
static func add_score(val: int):
score += val
static func is_unlocked(lvl: int) -> bool:
return unlocked_levels.has(lvl)
# skills/gdscript-mastery/scripts/type_checker.gd
@tool
extends EditorScript
## Type Checker Expert Pattern
## Scans codebase for missing type hints and static typing violations.
func _run() -> void:
print("=== GDScript Type Checker ===")
var violations: Array[Dictionary] = []
_scan_directory("res://", violations)
if violations.is_empty():
print("✓ All scripts use proper static typing!")
else:
print("❌ Found %d type violations:" % violations.size())
_print_violations(violations)
func _scan_directory(path: String, violations: Array[Dictionary]) -> void:
var dir := DirAccess.open(path)
if not dir:
return
dir.list_dir_begin()
var file_name := dir.get_next()
while file_name != "":
var full_path := path + file_name
if dir.current_is_dir():
if not file_name.begins_with(".") and file_name != "addons":
_scan_directory(full_path + "/", violations)
elif file_name.ends_with(".gd") and not file_name.begins_with("."):
_check_script(full_path, violations)
file_name = dir.get_next()
func _check_script(script_path: String, violations: Array[Dictionary]) -> void:
var file := FileAccess.open(script_path, FileAccess.READ)
if not file:
return
var line_number := 0
while not file.eof_reached():
line_number += 1
var line := file.get_line()
# Check for untyped variables
if _has_untyped_var(line):
violations.append({
"file": script_path,
"line": line_number,
"type": "untyped_variable",
"content": line.strip_edges()
})
# Check for untyped function returns
if _has_untyped_function(line):
violations.append({
"file": script_path,
"line": line_number,
"type": "untyped_function",
"content": line.strip_edges()
})
# Check for untyped parameters
if _has_untyped_params(line):
violations.append({
"file": script_path,
"line": line_number,
"type": "untyped_parameters",
"content": line.strip_edges()
})
func _has_untyped_var(line: String) -> bool:
# Match: var x = ... (without type hint)
# Don't match: var x: int = ... or var x := ...
var regex := RegEx.new()
regex.compile("^\\s*var\\s+\\w+\\s*=\\s*(?!.*:)")
return regex.search(line) != null and ":=" not in line
func _has_untyped_function(line: String) -> bool:
# Match: func name(...) without -> type
if not line.begins_with("func "):
return false
return "->" not in line and ":" in line # Has params but no return type
func _has_untyped_params(line: String) -> bool:
# Match function with parameters but without type hints
var regex := RegEx.new()
regex.compile("func\\s+\\w+\\(.*\\w+[^:,\\)].*\\)")
return regex.search(line) != null
func _print_violations(violations: Array[Dictionary]) -> void:
var grouped := {}
for v in violations:
if v["file"] not in grouped:
grouped[v["file"]] = []
grouped[v["file"]].append(v)
for file in grouped:
print("\n📄 %s:" % file)
for v in grouped[file]:
print(" Line %d [%s]: %s" % [v["line"], v["type"], v["content"]])
## EXPERT NOTE:
## Enable UNTYPED_DECLARATION warning in Project Settings for real-time checks.
## This script is for batch auditing before releases.
## CRITICAL: 20-40% performance gain with full static typing in hot paths.
# typed_collections_mastery.gd
# Using statically typed Arrays and Dictionaries for performance
extends Node
# EXPERT NOTE: Defining types allows the GDScript compiler to use
# optimized opcodes. Use this for all performance-critical data.
var active_enemies: Array[CharacterBody2D] = []
var player_stats: Dictionary[String, float] = {
"health": 100.0,
"mana": 50.0,
"speed": 10.0
}
func get_stat(stat_name: String) -> float:
# Typed dictionary access is faster than untyped
return player_stats.get(stat_name, 0.0)
func filter_dead_enemies() -> void:
# Typed iterator optimization
for enemy: CharacterBody2D in active_enemies:
if enemy.is_queued_for_deletion():
active_enemies.erase(enemy)
# typed_signal_definitions.gd
# Strict parameter enforcement for cross-module reliability
extends Node
# EXPERT NOTE: Typed signals prevent "String vs Int" mismatch bugs
# that are hard to track in large projects.
signal damage_taken(amount: int, origin: Vector2, critical: bool)
signal level_up(new_level: int)
func take_hit(dmg: int, pos: Vector2):
var is_crit = randf() > 0.8
# Compiler validates these arguments during emit() call
damage_taken.emit(dmg, pos, is_crit)
# unbind_signal_args.gd
# Discarding unneeded signal arguments safely
extends Node
func _ready() -> void:
# Some signals (like area_entered) pass an argument.
# unbind(1) tells Godot to drop that argument before calling our function.
$Area2D.area_entered.connect(_on_generic_event.unbind(1))
func _on_generic_event() -> void:
print("Something entered the area, but I didn't need its reference.")
GDScript Style Checklist
- [ ]
extendsis first line - [ ] Static typing used everywhere (
: int,-> void) - [ ] Signals are typed
- [ ] Node paths use
@onreadyand%UniqueNames - [ ] Function names are
snake_case - [ ] Private methods/vars prefixed with
_ - [ ] No
get_node()inside_process()or loops