From godot-prompter
Writes production-grade GDScript with performance idioms, metaprogramming, @tool lifecycle, async pitfalls, signal/Callable trade-offs, and profiler-driven idioms.
How this skill is triggered — by the user, by Claude, or both
Slash command
/godot-prompter:gdscript-advancedThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Production-grade GDScript depth — for shipping games, not for learning the language. Pair with **gdscript-patterns** for fundamentals.
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:
class_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:
var 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.
var 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:
var 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:
_processdoingif 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:
var 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:
heavy_recompute.call_deferred()
Object.set / Object.get / Object.has_method — for truly dynamic code (script reloading, modding):
if obj.has_method("on_damaged"):
obj.call("on_damaged", 25)
Security gotcha: Never pass
obj.call(user_string, ...)whereuser_stringcomes from save files, network, or mod content without an allowlist.call("queue_free")is a free crash. Match against a known set:
const 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:
@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):
func _notification(what: int) -> void:
if what == NOTIFICATION_EDITOR_PRE_SAVE:
_bake_preview()
Common pitfall: a
@toolscript that callsget_tree().create_timer()at editor time. Editor has no main loop in some contexts — guard withis_editor_hint().
See references/tool-script-recipes.md for full
@toolpatterns 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:
# 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:
# BAD if `health_changed` never fires (e.g., entity already at full HP)
await health.health_changed
Fix: use a timeout race:
var 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_treeor 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:
var 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:
for 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.
var 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_MODIFICATIONwarning flags modifying a temporary (discarded) value — e.g. a built-inPacked*Arrayproperty changed through a complex assignment chain or a non-constmethod call, where only a temporary copy changes and the property keeps its old value. Controlled bydebug/gdscript/warnings/confusable_temporary_modification(default1, 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 pitfallsnpx claudepluginhub jame581/godotprompter --plugin godot-prompterWrites idiomatic, statically typed GDScript for Godot 4.x using node lifecycle, @export/@onready annotations, signals, and await. Use when editing .gd files or porting 3.x scripts.
Provides Godot 4 GDScript patterns for architecture, signals, scenes, state machines, and optimization. Useful for building games, game systems, and best practices.
Production patterns for Godot 4.x game development with GDScript, covering signals, scenes, state machines, and optimization.