Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use when reviewing GDScript or C# Godot code — checklist of best practices, common anti-patterns, and Godot-specific pitfalls
.claude/skills/jame581-godot-code-review/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-10 | ✗→✓ | ▲ Improved | 167% | 0% |
| case-17 | ✗→✓ | ▲ Improved | 171% | 0% |
| case-12 | ✓→✓ | = Same ✓ | 204% | 0% |
| case-13 | ✓→✓ | = Same ✓ | 314% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 154% | 0% |
A structured review guide for Godot 4.3+ projects covering GDScript and C#. Work through each checklist section, then produce a review summary using the output template at the end.
> Related skills: godot-testing for TDD and test coverage, scene-organization for scene tree best practices, godot-optimization for performance review.
extends hierarchiesget_parent() chains@onready (GDScript) or GetNode<T>() (C#) targets direct children or named paths within the same sceneget_parent() chaingdscript# BAD: tight coupling, breaks if the tree changes func take_damage(amount: int) -> void: get_parent().get_parent().get_node("HUD").update_health(health)
csharp// BAD: tight coupling, breaks if the tree changes public void TakeDamage(int amount) { GetParent().GetParent().GetNode("HUD").Call("UpdateHealth", _health); }
gdscript# GOOD: parent/ancestor listens; child stays decoupled signal health_changed(new_health: int) func take_damage(amount: int) -> void: health -= amount health_changed.emit(health)
csharp// GOOD: parent/ancestor listens; child stays decoupled [Signal] public delegate void HealthChangedEventHandler(int newHealth); public void TakeDamage(int amount) { _health -= amount; EmitSignal(SignalName.HealthChanged, _health); }
snake_caseclass_name use PascalCaseSCREAMING_SNAKE_CASE@export variables include an explicit typegdscriptvar speed = 200 var health = 100 func move(direction): position += direction * speed func heal(amount): health += amount return health
csharp// BAD: no explicit types, weak contracts float speed = 200; int health = 100; public void Move(object direction) { Position += (Vector2)direction * speed; } public object Heal(object amount) { health += (int)amount; return health; }
gdscriptclass_name PlayerController extends CharacterBody2D signal health_changed(new_health: int) signal player_died() const MAX_HEALTH: int = 100 const BASE_SPEED: float = 200.0 @export var speed: float = BASE_SPEED @export var max_health: int = MAX_HEALTH var health: int = max_health func move(direction: Vector2) -> void: velocity = direction * speed move_and_slide() func heal(amount: int) -> int: health = mini(health + amount, max_health) health_changed.emit(health) return health
csharp// GOOD: strongly typed, proper C# conventions public partial class PlayerController : CharacterBody2D { [Signal] public delegate void HealthChangedEventHandler(int newHealth); [Signal] public delegate void PlayerDiedEventHandler(); private const int MaxHealth = 100; private const float BaseSpeed = 200f; [Export] public float Speed { get; set; } = BaseSpeed; [Export] public int MaxHp { get; set; } = MaxHealth; private int _health; public override void _Ready() { _health = MaxHp; } public void Move(Vector2 direction) { Velocity = direction * Speed; MoveAndSlide(); } public int Heal(int amount) { _health = Mathf.Min(_health + amount, MaxHp); EmitSignal(SignalName.HealthChanged, _health); return _health; } }
partial class to allow Godot source generators to workPascalCase; local variables use camelCase[Export] properties use PascalCase[Signal] delegates follow the <EventName>EventHandler naming patternGetNode<T>() results are null-checked or cached in _Ready() and validatedcsharp// GOOD public partial class PlayerController : CharacterBody2D { [Signal] public delegate void HealthChangedEventHandler(int newHealth); [Export] public float Speed { get; set; } = 200f; [Export] public int MaxHealth { get; set; } = 100; private int _health; private AnimationPlayer _animationPlayer = null!; public override void _Ready() { _animationPlayer = GetNode<AnimationPlayer>("AnimationPlayer"); // Validate at startup rather than silently failing later if (_animationPlayer is null) GD.PushError("AnimationPlayer node not found on PlayerController"); _health = MaxHealth; } public void TakeDamage(int amount) { _health = Mathf.Max(_health - amount, 0); EmitSignal(SignalName.HealthChanged, _health); } }
get_node() / $NodePath is never called inside _process() or _physics_process() — always cache with @onreadyload() is not called in hot paths — use preload() for compile-time loading or cache the result_process() is disabled (set_process(false)) when the node does not need per-frame updatesStringName (or &"string" literal) is used for comparisons inside _process() or tight loops_process()gdscript# BAD: get_node() traverses the tree every frame func _process(delta: float) -> void: get_node("HUD/HealthBar").value = health get_node("HUD/Label").text = str(health)
csharp// BAD: GetNode() traverses the tree every frame public override void _Process(double delta) { GetNode<ProgressBar>("HUD/HealthBar").Value = _health; GetNode<Label>("HUD/Label").Text = _health.ToString(); }
@onreadygdscript# GOOD: resolved once at scene load @onready var _health_bar: ProgressBar = $HUD/HealthBar @onready var _health_label: Label = $HUD/Label func _process(delta: float) -> void: _health_bar.value = health _health_label.text = str(health)
csharp// GOOD: resolved once in _Ready() private ProgressBar _healthBar = null!; private Label _healthLabel = null!; public override void _Ready() { _healthBar = GetNode<ProgressBar>("HUD/HealthBar"); _healthLabel = GetNode<Label>("HUD/Label"); } public override void _Process(double delta) { _healthBar.Value = _health; _healthLabel.Text = _health.ToString(); }
gdscript# BAD: new String allocation compared each frame if animation_name == "run": pass # GOOD: StringName literal, no allocation if animation_name == &"run": pass
csharp// BAD: allocates a new StringName each frame if (animationName == "run") { } // GOOD: cache StringName as a static field private static readonly StringName RunAnim = new("run"); public override void _Process(double delta) { if (animationName == RunAnim) { } }
_unhandled_input() is preferred over _input() to allow UI controls to consume events firstInput.get_vector() / Input.is_action_pressed() inside _physics_process()_unhandled_input()gdscript# Continuous movement — physics process func _physics_process(delta: float) -> void: var direction: Vector2 = Input.get_vector( &"ui_left", &"ui_right", &"ui_up", &"ui_down" ) velocity = direction * speed move_and_slide() # Discrete action — unhandled input func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed(&"jump"): _jump()
csharp// Continuous movement — physics process public override void _PhysicsProcess(double delta) { Vector2 direction = Input.GetVector( "ui_left", "ui_right", "ui_up", "ui_down" ); Velocity = direction * Speed; MoveAndSlide(); } // Discrete action — unhandled input public override void _UnhandledInput(InputEvent @event) { if (@event.IsActionPressed("jump")) { Jump(); } }
_ready() or wired in the editor — not in _process() or one-off callbacksgdscript# Good signal names signal health_changed(new_health: int) # past tense signal enemy_died() # past tense signal item_collected(item: ItemData) # past tense # Bad signal names (present/imperative tense) # signal update_health(value: int) # signal die() # signal collect_item(item: ItemData)
csharp// Good signal names — past tense, EventHandler suffix [Signal] public delegate void HealthChangedEventHandler(int newHealth); [Signal] public delegate void EnemyDiedEventHandler(); [Signal] public delegate void ItemCollectedEventHandler(ItemData item); // Bad signal names (present/imperative tense) // public delegate void UpdateHealthEventHandler(int value); // public delegate void DieEventHandler(); // public delegate void CollectItemEventHandler(ItemData item);
gdscript# Parent connects to child signal in _ready() func _ready() -> void: $Enemy.enemy_died.connect(_on_enemy_died) $Player.health_changed.connect(_on_player_health_changed)
csharp// Parent connects to child signal in _Ready() public override void _Ready() { GetNode<Enemy>("Enemy").EnemyDied += OnEnemyDied; GetNode<Player>("Player").HealthChanged += OnPlayerHealthChanged; }
preload() is used for resources known at edit time (scenes, textures, audio); load() is used for paths resolved at runtimeResourceLoader.load_threaded_request() to avoid frame stallsqueue_free(), not free(), to avoid use-after-free crashesgdscript# Compile-time — path is validated by the editor const BULLET_SCENE: PackedScene = preload("res://scenes/bullet.tscn") # Runtime — path comes from data func _load_level(path: String) -> void: ResourceLoader.load_threaded_request(path) func _check_load(path: String) -> void: if ResourceLoader.load_threaded_get_status(path) == ResourceLoader.THREAD_LOAD_LOADED: var scene: PackedScene = ResourceLoader.load_threaded_get(path) get_tree().change_scene_to_packed(scene) # Cleanup func _on_enemy_died() -> void: queue_free() # safe — deferred until end of frame
csharp// Compile-time equivalent — load once in a static field or _Ready() private static readonly PackedScene BulletScene = GD.Load<PackedScene>("res://scenes/bullet.tscn"); // Runtime — path comes from data private void LoadLevel(string path) { ResourceLoader.LoadThreadedRequest(path); } private void CheckLoad(string path) { if (ResourceLoader.LoadThreadedGetStatus(path) == ResourceLoader.ThreadLoadStatus.Loaded) { var scene = ResourceLoader.LoadThreadedGet(path) as PackedScene; GetTree().ChangeSceneToPacked(scene); } } // Cleanup private void OnEnemyDied() { QueueFree(); // safe — deferred until end of frame }
| Pattern | Problem | Fix | |---|---|---| | await get_tree().create_timer(t).timeout after queue_free() | Timer signal fires on a freed node, causing errors | Check is_instance_valid(self) after await, or use create_tween() which auto-stops | | Fragile node paths like $A/B/C/D/E | Breaks silently when the scene tree is reorganized | Refactor to direct children + signals, or export a NodePath | | call_deferred() used everywhere | Defers are appropriate for cross-frame safety, not a general solution; overuse hides real design issues | Only defer when crossing physics/main thread boundaries or breaking a call cycle | | set_physics_process(true) called inside _physics_process() | Redundant call every frame; wastes CPU | Call once at the point you actually want to enable/disable processing | | Directly setting position on a CharacterBody2D | Bypasses collision; teleports the body and can cause tunnelling | Use move_and_slide() with velocity; only set position/global_position for intentional teleports |
Use this template when delivering a review:
## Code Review — <FileName or Feature>
### Critical
Issues that will cause bugs, crashes, or significant performance problems.
- [ ] <node/line> — <issue> — **Suggested fix:** <fix>
### Improvements
Code quality, style, or maintainability concerns that should be addressed.
- [ ] <node/line> — <issue> — **Suggested fix:** <fix>
### Positive
What the code does well — reinforce good patterns.
- <observation>
---
Reviewed against: Godot 4.3+ best practices## Code Review — PlayerController.gd
### Critical
- [ ] _process() line 42 — `get_node("HUD/HealthBar")` called every frame — **Suggested fix:** Cache with `@onready var _health_bar: ProgressBar = $HUD/HealthBar`
- [ ] take_damage() line 67 — no type hints on parameter or return — **Suggested fix:** `func take_damage(amount: int) -> void:`
### Improvements
- [ ] Line 12 — signal `updateHealth` should be past tense — **Suggested fix:** Rename to `health_changed`
- [ ] Line 8 — `var speed = 200` missing type hint — **Suggested fix:** `var speed: float = 200.0`
### Positive
- Signals are declared at the top of the file
- Constants correctly use SCREAMING_SNAKE_CASE
- `queue_free()` used correctly for cleanup
---
Reviewed against: Godot 4.3+ best practices| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-12 | pass→pass | 13,421 | 13,582 | +1% | 1 | 1 | 0% | 1,996 | 6,067 | +204% | 0 | 0 | — |
case-13 | pass→pass | 7,734 | 8,567 | +11% | 1 | 1 | 0% | 1,287 | 5,333 | +314% | 0 | 0 | — |
case-01 | pass→pass | 11,786 | 8,653 | -27% | 1 | 1 | 0% | 2,175 | 5,527 | +154% | 0 | 0 | — |
case-11 | pass→pass | 9,987 | 7,225 | -28% | 1 | 1 | 0% | 1,600 | 5,107 | +219% | 0 | 0 | — |
case-02 | pass→pass | 10,626 | 7,944 | -25% | 1 | 1 | 0% | 1,889 | 5,357 | +184% | 0 | 0 | — |
case-03 | pass→pass | 2,843 | 3,031 | +7% | 1 | 1 | 0% | 457 | 4,405 | +864% | 0 | 0 | — |
case-04 | pass→pass | 7,086 | 4,563 | -36% | 1 | 1 | 0% | 1,140 | 4,670 | +310% | 0 | 0 | — |
case-05 | pass→pass | 4,956 | 5,346 | +8% | 1 | 1 | 0% | 846 | 4,846 | +473% | 0 | 0 | — |
case-06 | pass→pass | 3,438 | 3,238 | -6% | 1 | 1 | 0% | 592 | 4,476 | +656% | 0 | 0 | — |
case-07 | pass→pass | 14,116 | 12,361 | -12% | 1 | 1 | 0% | 2,481 | 6,231 | +151% | 0 | 0 | — |
case-08 | pass→pass | 6,991 | 4,128 | -41% | 1 | 1 | 0% | 1,066 | 4,672 | +338% | 0 | 0 | — |
case-09 | pass→pass | 9,548 | 9,805 | +3% | 1 | 1 | 0% | 1,770 | 5,902 | +233% | 0 | 0 | — |
case-10 | fail→pass | 12,387 | 10,568 | -15% | 1 | 1 | 0% | 2,089 | 5,588 | +167% | 0 | 0 | — |
case-14 | pass→pass | 12,253 | 10,552 | -14% | 1 | 1 | 0% | 2,119 | 5,705 | +169% | 0 | 0 | — |
case-15 | pass→pass | 3,306 | 4,706 | +42% | 1 | 1 | 0% | 540 | 4,847 | +798% | 0 | 0 | — |
case-16 | pass→pass | 11,456 | 9,539 | -17% | 1 | 1 | 0% | 1,870 | 5,504 | +194% | 0 | 0 | — |
case-17 | fail→pass | 13,046 | 11,202 | -14% | 1 | 1 | 0% | 2,191 | 5,929 | +171% | 0 | 0 | — |
case-18 | pass→pass | 11,147 | 7,160 | -36% | 1 | 1 | 0% | 2,011 | 5,286 | +163% | 0 | 0 | — |
case-19 | pass→pass | 7,188 | 4,173 | -42% | 1 | 1 | 0% | 1,211 | 4,662 | +285% | 0 | 0 | — |
case-20 | pass→pass | 6,504 | 4,651 | -28% | 1 | 1 | 0% | 1,443 | 4,964 | +244% | 0 | 0 | — |
case-21 | pass→pass | 10,461 | 12,700 | +21% | 1 | 1 | 0% | 2,104 | 6,361 | +202% | 0 | 0 | — |
case-22 | pass→pass | 7,987 | 7,451 | -7% | 1 | 1 | 0% | 1,636 | 5,367 | +228% | 0 | 0 | — |
case-23 | pass→pass | 6,188 | 5,618 | -9% | 1 | 1 | 0% | 1,084 | 5,015 | +363% | 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. 23 cases were attempted. The headline lift of +9 percentage points is the difference between those two pass rates over the 23 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.