Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use when writing production-grade GDScript — performance idioms, metaprogramming, @tool lifecycle, async pitfalls, signal/Callable trade-offs, profiler-driven idioms, and common pitfalls
.claude/skills/jame581-gdscript-advanced/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-03 | ✗→✓ | ▲ Improved | 46% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 62% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 113% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 155% | 0% |
| case-19 | ✗→✓ | ▲ Improved | 105% | 0% |
Production-grade GDScript depth — for shipping games, not for learning the language. Pair with gdscript-patterns for fundamentals.
> Related skills: gdscript-patterns for language fundamentals, godot-optimization for engine-side perf work, godot-debugging for runtime diagnosis, csharp-godot for the C# alternative.
> Intent: This skill is GDScript-only by design (allowlisted). C# users should read csharp-godot. Adding C# parity here would undermine the audience split.
You're past gdscript-patterns when:
@tool lifecycle correctnessawait deadlocks or Callable lifetime bugsThis skill assumes you already know typed parameters, @onready, await, match, and lambdas (covered in gdscript-patterns).
Static vars and methods (Godot 4.4+) avoid per-instance overhead:
gdscriptclass_name Tally extends Node static var _global_score: int = 0 static func add_score(amount: int) -> void: _global_score += amount static func get_score() -> int: return _global_score
Avoid singletons-as-autoloads when a static method on a class would do.
Vector2i vs Vector2 / Vector3i vs Vector3 — integer vectors are 30-40% faster on hot paths (tile coords, grid math). Convert to float only at the rendering boundary:
gdscriptvar grid_pos: Vector2i = Vector2i(8, 12) # cheap var world_pos: Vector2 = Vector2(grid_pos) * TILE_SIZE # convert at boundary
PackedArray\ over generic Array — PackedInt32Array, PackedFloat32Array, PackedVector2Array, etc. allocate contiguous memory and skip Variant boxing. Use them for buffers, vertex arrays, hot-loop accumulators.
gdscriptvar positions: PackedVector3Array = PackedVector3Array() positions.resize(1000) # one allocation for i in 1000: positions[i] = Vector3(i, 0, 0)
Typed Dictionary access — typed dicts (Godot 4.4+) skip the Variant unbox per read:
gdscriptvar stats: Dictionary[String, int] = {} stats["hp"] = 100 # no boxing
is_instance_valid vs null check — is_instance_valid() does an engine-side lookup; != null is a pointer compare. Prefer != null after @onready assignment; reserve is_instance_valid() for nodes that may be queue_free'd while a reference is held.
> Common pitfall: _process doing if is_instance_valid(target) once per frame burns ~1µs per call — tiny per-call but multiplies fast.
Callable.bind, Callable.call, Callable.call_deferred give you dynamic dispatch without Object.call(name) security risks.
Binding arguments:
gdscriptvar greeter: Callable = print_named.bind("Player") greeter.call() # prints "Hello, Player" func print_named(name: String) -> void: print("Hello, %s" % name)
Deferred calls — run on the next frame's idle phase, useful for cross-thread or signal-storm safety:
gdscriptheavy_recompute.call_deferred()
Object.set / Object.get / Object.has_method — for truly dynamic code (script reloading, modding):
gdscriptif obj.has_method("on_damaged"): obj.call("on_damaged", 25)
> Security gotcha: Never pass obj.call(user_string, ...) where user_string comes from save files, network, or mod content without an allowlist. call("queue_free") is a free crash. Match against a known set:
gdscriptconst ALLOWED_RPCS: PackedStringArray = ["take_damage", "apply_buff", "set_position"] if user_method in ALLOWED_RPCS and obj.has_method(user_method): obj.call(user_method, args)
> See references/metaprogramming-recipes.md for full Callable patterns and the modding security model.
@tool lifecycle@tool scripts run in the editor as well as in-game. Two failure modes dominate:
The guard:
gdscript@tool extends Node func _ready() -> void: if Engine.is_editor_hint(): _setup_editor_preview() else: _setup_game_runtime()
Editor notifications — use _notification for editor lifecycle events (NOTIFICATION_EDITOR_PRE_SAVE, NOTIFICATION_EDITOR_POST_SAVE, NOTIFICATION_PARENTED):
gdscriptfunc _notification(what: int) -> void: if what == NOTIFICATION_EDITOR_PRE_SAVE: _bake_preview()
> Common pitfall: a @tool script that calls get_tree().create_timer() at editor time. Editor has no main loop in some contexts — guard with is_editor_hint().
> See references/tool-script-recipes.md for full @tool patterns including editor preview, baking, and procedural mesh generation.
await is sugar over signal-yielding. It has three trap shapes:
Trap 1 — await in _ready delays children's ready order:
gdscript# BAD: children of this node ready BEFORE this _ready() finishes func _ready() -> void: await get_tree().create_timer(1.0).timeout initialize_children() # children already ready'd against an uninitialized parent
Fix: do not await in _ready. Move the await to a separate setup function.
Trap 2 — Awaiting a signal that never fires deadlocks the calling coroutine:
gdscript# BAD if `health_changed` never fires (e.g., entity already at full HP) await health.health_changed
Fix: use a timeout race:
gdscriptvar timer := get_tree().create_timer(2.0) var winner := await Signal.any([health.health_changed, timer.timeout])
(Or check the precondition before awaiting.)
Trap 3 — Callable referencing a freed object — when the awaiter is freed mid-await, the resumed coroutine crashes. Use await ToSignal() patterns where the engine handles the lifecycle.
Signal — many-to-many, decoupled, edge-triggered. Slight per-emit overhead from the connection list lookup.
Callable — one-to-one, explicit, level-triggered. Cheaper per call but tighter coupling.
Use signals for:
Use callables for:
call_deferred)tween_method takes a Callable)> Common pitfall: connecting a lambda to a signal stores the lambda's captured environment forever. If the captured object is freed, you get warnings. Disconnect explicitly in _exit_tree or use bound methods instead.
Open the Debugger → Profiler panel. The patterns that show up most often:
| Profiler hot spot | Likely cause | Fix | |---|---|---| | String allocation in _process | print() / "%s" % var per frame | Pre-format outside the loop, or batch logs with a circular buffer | | Object.get_node showing high self-time | Repeated $Path/Sub/Node per frame | Cache in @onready var | | Signal.emit showing high call count | Per-frame signal storms (e.g., position update) | Throttle to 10 Hz, or use a polling pattern | | CharacterBody.move_and_slide self-time | Many character bodies on one frame | Scale by distance from camera; use Area for cheap detection | | GDScript GC spikes | Allocator churn from temp Arrays/Strings | Pool the arrays; pre-allocate at startup |
> See references/profiler-recipes.md for before/after annotated examples for each row.
Lambda captures by reference — the lambda sees the current value of captured vars, not the value at definition time:
gdscriptvar callbacks: Array[Callable] = [] for i in 5: callbacks.append(func(): print(i)) # all five print 5 (or 4 — depends on engine)
Fix: capture by bind:
gdscriptfor i in 5: callbacks.append((func(idx): print(idx)).bind(i))
@onready ordering — @onready vars are set after _init but before _ready. Children's _ready runs before parent's _ready. So:
_ready unless you're sure the parent is initializedchild.setup_with(self) from its own _readyStatic var lifecycle across scene reload — static vars on a class persist for the lifetime of the engine, not the scene. Reloading a scene does NOT reset them. If you need a per-scene singleton, use an autoload, not a static var.
Resource sharing surprises — @export var item: ItemData with the same Resource asset in two scenes shares state by reference. Mutating one mutates the other. Use item.duplicate() when each instance needs its own state.
Packed-array property setters skip element writes
> ⚠️ Changed in Godot 4.7: Setting an element of a packed-array property (e.g. obj.packed_prop[i] = x) no longer calls the setter for the entire packed array property. Code that relied on the setter firing for per-element writes silently breaks — reassign the whole array to trigger the setter. See the 4.7 migration guide.
gdscriptvar points: PackedVector2Array: set(value): points = value _rebuild_mesh() func move_point() -> void: points[0] = Vector2.ONE # 4.6: setter (and _rebuild_mesh) ran; 4.7+: it does NOT var updated := points # fix: modify a copy... updated[0] = Vector2.ONE points = updated # ...then reassign — the setter fires
> Godot 4.7+: the new CONFUSABLE_TEMPORARY_MODIFICATION warning flags modifying a temporary (discarded) value — e.g. a built-in Packed*Array property changed through a complex assignment chain or a non-const method call, where only a temporary copy changes and the property keeps its old value. Controlled by debug/gdscript/warnings/confusable_temporary_modification (default 1, warn).
@tool, guard editor vs runtime branches with Engine.is_editor_hint()await calls for deadlock risk (signal that may not fire) and _ready ordering bugs_exit_tree@onready ordering, static var lifecycle, Resource sharing, and packed-array property setters (Godot 4.7) for the listed pitfalls| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | pass→pass | 14,623 | 8,646 | -41% | 1 | 1 | 0% | 2,468 | 4,565 | +85% | 0 | 0 | — |
case-02 | pass→pass | 14,878 | 10,514 | -29% | 1 | 1 | 0% | 2,409 | 4,797 | +99% | 0 | 0 | — |
case-03 | fail→pass | 17,438 | 6,225 | -64% | 1 | 1 | 0% | 2,770 | 4,057 | +46% | 0 | 0 | — |
case-04 | fail→fail | 11,474 | 9,942 | -13% | 1 | 1 | 0% | 1,975 | 4,690 | +137% | 0 | 0 | — |
case-05 | fail→pass | 16,025 | 9,414 | -41% | 1 | 1 | 0% | 2,918 | 4,741 | +62% | 0 | 0 | — |
case-06 | pass→pass | 9,455 | 7,198 | -24% | 1 | 1 | 0% | 1,644 | 4,215 | +156% | 0 | 0 | — |
case-07 | pass→pass | 6,542 | 2,953 | -55% | 1 | 1 | 0% | 1,204 | 3,490 | +190% | 0 | 0 | — |
case-08 | fail→pass | 12,211 | 7,589 | -38% | 1 | 1 | 0% | 2,026 | 4,323 | +113% | 0 | 0 | — |
case-09 | fail→fail | 20,675 | 11,663 | -44% | 1 | 1 | 0% | 3,714 | 5,144 | +39% | 0 | 0 | — |
case-10 | pass→pass | 13,926 | 8,374 | -40% | 1 | 1 | 0% | 2,309 | 4,453 | +93% | 0 | 0 | — |
case-11 | pass→pass | 12,804 | 7,141 | -44% | 1 | 1 | 0% | 2,324 | 4,268 | +84% | 0 | 0 | — |
case-12 | fail→pass | 9,632 | 7,297 | -24% | 1 | 1 | 0% | 1,663 | 4,244 | +155% | 0 | 0 | — |
case-13 | pass→pass | 9,322 | 6,335 | -32% | 1 | 1 | 0% | 1,589 | 4,016 | +153% | 0 | 0 | — |
case-14 | pass→pass | 11,171 | 7,165 | -36% | 1 | 1 | 0% | 1,981 | 4,268 | +115% | 0 | 0 | — |
case-15 | pass→pass | 6,756 | 2,351 | -65% | 1 | 1 | 0% | 1,120 | 3,439 | +207% | 0 | 0 | — |
case-16 | pass→pass | 7,074 | 6,506 | -8% | 1 | 1 | 0% | 1,194 | 4,077 | +241% | 0 | 0 | — |
case-17 | pass→pass | 11,967 | 9,509 | -21% | 1 | 1 | 0% | 2,019 | 4,705 | +133% | 0 | 0 | — |
case-18 | pass→pass | 15,895 | 8,941 | -44% | 1 | 1 | 0% | 2,970 | 4,707 | +58% | 0 | 0 | — |
case-19 | fail→pass | 11,485 | 7,575 | -34% | 1 | 1 | 0% | 2,116 | 4,348 | +105% | 0 | 0 | — |
case-20 | pass→pass | 8,400 | 8,049 | -4% | 1 | 1 | 0% | 1,585 | 4,498 | +184% | 0 | 0 | — |
case-21 | fail→fail | 13,162 | 7,388 | -44% | 1 | 1 | 0% | 2,634 | 4,376 | +66% | 0 | 0 | — |
case-22 | pass→pass | 15,014 | 11,524 | -23% | 1 | 1 | 0% | 2,615 | 5,034 | +93% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +23 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.