Detects performance bottlenecks in Godot projects including expensive _process functions, get_node() calls in loops, instantiations in _process, and provides optimization suggestions with Godot profiler integration
Analyzes Godot projects to detect performance bottlenecks in GDScript code. Identifies expensive operations in frame-critical functions like `_process`, `_physics_process`, and `_draw`. Provides actionable optimization suggestions with before/after code examples and integrates with Godot's built-in profiler for validation.
**Core principle:** Frame time is precious—expensive operations belong in `_ready` or signal handlers, not per-frame callbacks.
**Use when:**
**Don't use for:**
**Trigger:** Functions with >15 lines of code in `_process` or `_physics_process`
**Detection:**
```bash
rg "^func _process" -A 50 --glob "*.gd" | wc -l
```
**Thresholds:**
**Pattern:**
```gdscript
func _process(delta):
get_node("UI/HealthBar").value = health # Called every frame!
get_node("Player").position = position
```
**Detection regex:**
```regex
func _process.*\n(?:.*\n)*?\s+get_node\(
```
**Pattern:**
```gdscript
func _process(delta):
var bullet = Bullet.new() # Memory churn!
add_child(bullet)
```
**Detection keywords:**
**Anti-patterns:**
**Steps:**
1. Enable Godot profiler (Debug > Profiler)
2. Run game for 60 seconds of typical gameplay
3. Identify functions with high "Time (ms)" values
4. Sort by "Time %" to find top offenders
5. Look for functions >0.1ms per frame
**Interpretation:**
**Indicators:**
**Detection:**
```bash
rg "\.new\(\)|instantiate\(\)" -B 5 -A 2 --glob "*.gd" | \
rg -A 10 "func _process|func _physics_process"
```
**Check:**
**Optimization targets:**
**Red flags:**
**Before:**
```gdscript
extends CharacterBody2D
func _process(delta):
get_node("UI/HealthBar").value = health
get_node("UI/ManaBar").value = mana
get_node("UI/LevelLabel").text = str(level)
```
**After:**
```gdscript
extends CharacterBody2D
@onready var health_bar = $UI/HealthBar
@onready var mana_bar = $UI/ManaBar
@onready var level_label = $UI/LevelLabel
func _process(delta):
health_bar.value = health
mana_bar.value = mana
level_label.text = str(level)
```
**Impact:** Eliminates 3 node lookups per frame (~0.01ms each)
**Before:**
```gdscript
func _process(delta):
var gravity = ProjectSettings.get("physics/2d/default_gravity")
velocity.y += gravity * delta
```
**After:**
```gdscript
var gravity
func _ready():
gravity = ProjectSettings.get("physics/2d/default_gravity")
func _process(delta):
velocity.y += gravity * delta
```
**Before:**
```gdscript
func shoot():
var bullet = BulletScene.instantiate()
bullet.position = global_position
get_parent().add_child(bullet)
```
**After:**
```gdscript
var bullet_pool: Array[Bullet] = []
func _ready():
# Pre-instantiate bullets
for i in range(50):
var bullet = BulletScene.instantiate()
bullet.hide()
bullet_pool.append(bullet)
get_parent().add_child(bullet)
func shoot():
for bullet in bullet_pool:
if not bullet.visible:
bullet.position = global_position
bullet.show()
bullet.activate()
return
```
**Impact:** Eliminates instantiation overhead during gameplay
**Before:**
```gdscript
func _process(delta):
# Checking every frame if health changed
if health != previous_health:
update_health_bar()
previous_health = health
```
**After:**
```gdscript
signal health_changed(new_health)
var health = 100:
set(value):
if health != value:
health = value
health_changed.emit(health)
func _ready():
health_changed.connect(update_health_bar)
```
**Before:**
```gdscript
func _process(delta):
for enemy in enemies:
if enemy.position.distance_to(player.position) < 100:
enemy.target_player()
```
**After:**
```gdscript
var check_timer = 0.0
const CHECK_INTERVAL = 0.1 # Check 10x per second, not 60x
func _process(delta):
check_timer += delta
if check_timer >= CHECK_INTERVAL:
check_timer = 0
update_enemy_targets()
func update_enemy_targets():
for enemy in enemies:
if enemy.position.distance_to(player.position) < 100:
enemy.target_player()
```
1. Run game with Debug > Start with Profiler
2. Or click the "Profiler" tab in the bottom panel while game runs
3. Enable specific monitors:
- CPU Time
- Function Time
- Node Count
- Memory
- Draw Calls
**Frame Time (ms):**
**Function Breakdown:**
**Memory Monitor:**
```
1. Establish baseline (unoptimized)
└─ Record profiler data for 60 seconds
2. Identify top 3 time consumers
└─ Sort by "Time %" in profiler
3. Apply optimization
└─ Use patterns above
4. Validate improvement
└─ Profile again, compare metrics
└─ Verify frame time reduced
5. Repeat for next bottleneck
```
**Problem:**
```gdscript
extends Control
func _process(delta):
# Called every frame - 5 node lookups!
get_node("HealthBar").value = player.health
get_node("ManaBar").value = player.mana
get_node("LevelLabel").text = "Level: " + str(player.level)
get_node("XpBar").value = player.xp
get_node("GoldLabel").text = "Gold: " + str(player.gold)
```
**Profiler Output:**
```
Function | Time (ms) | Time %
----------------------------------------
_process | 0.18 | 12.3%
get_node | 0.15 | 10.2% (x5)
```
**Optimized:**
```gdscript
extends Control
@onready var health_bar = $HealthBar
@onready var mana_bar = $ManaBar
@onready var level_label = $LevelLabel
@onready var xp_bar = $XpBar
@onready var gold_label = $GoldLabel
func _ready():
# Connect to player signals instead of polling
player.health_changed.connect(_on_health_changed)
player.mana_changed.connect(_on_mana_changed)
player.leveled_up.connect(_on_leveled_up)
player.xp_changed.connect(_on_xp_changed)
player.gold_changed.connect(_on_gold_changed)
func _on_health_changed(value): health_bar.value = value
func _on_mana_changed(value): mana_bar.value = value
func _on_leveled_up(level): level_label.text = "Level: " + str(level)
func _on_xp_changed(value): xp_bar.value = value
func _on_gold_changed(value): gold_label.text = "Gold: " + str(value)
```
**Result:**
```
Function | Time (ms) | Time %
----------------------------------------
_process | 0.00 | 0.0% (removed!)
_on_health_changed| 0.01 | 0.7% (event-driven)
```
**Problem:**
```gdscript
extends Node2D
func _process(delta):
if enemies.size() < max_enemies:
var enemy = EnemyScene.instantiate() # Memory churn!
enemy.position = random_position()
add_child(enemy)
enemies.append(enemy)
```
**Memory leak:** Continuous instantiation without pooling
**Optimized:**
```gdscript
extends Node2D
var spawn_timer = 0.0
const SPAWN_RATE = 2.0 # Check spawn every 2 seconds
func _process(delta):
spawn_timer += delta
if spawn_timer >= SPAWN_RATE:
spawn_timer = 0
try_spawn()
func try_spawn():
if enemies.size() < max_enemies:
spawn_enemy()
func spawn_enemy():
# Consider object pool for frequent spawns
var enemy = EnemyScene.instantiate()
enemy.position = random_position()
add_child(enemy)
enemies.append(enemy)
```
**Additional improvement:** Use object pooling for frequently spawned enemies
**Problem:**
```gdscript
extends CharacterBody2D
func _physics_process(delta):
# Heavy calculation every physics frame
var nearby = get_tree().get_nodes_in_group("enemies")
for enemy in nearby:
if global_position.distance_to(enemy.global_position) < detection_radius:
enemy.set_target(self)
# Do physics
velocity.y += gravity * delta
move_and_slide()
```
**Profiler Output:**
```
Function | Time (ms) | Time %
--------------------------------------------
_physics_process | 0.45 | 28.5%
get_nodes_in_group | 0.25 | 15.8%
distance_to | 0.12 | 7.6%
```
**Optimized:**
```gdscript
extends CharacterBody2D
var detection_timer = 0.0
const DETECTION_RATE = 0.2 # 5x per second
func _physics_process(delta):
# Separate physics from AI
velocity.y += gravity * delta
move_and_slide()
# Run detection less frequently
detection_timer += delta
if detection_timer >= DETECTION_RATE:
detection_timer = 0
update_enemy_detection()
func update_enemy_detection():
var nearby = get_tree().get_nodes_in_group("enemies")
for enemy in nearby:
if global_position.distance_to(enemy.global_position) < detection_radius:
enemy.set_target(self)
```
**Result:** Physics frame time reduced by ~60%
A performance optimization is successful when:
1. **Profile before:** Record baseline metrics
2. **Apply optimization:** Make targeted changes
3. **Profile after:** Compare metrics
4. **Verify in-game:** Test actual gameplay feels smoother
5. **Check edge cases:** Ensure optimization works in all scenarios
| Pattern | Detection | Fix | Impact |
|---------|-----------|-----|--------|
| get_node() in _process | Search: `get_node` after `_process` | Cache in `@onready` | ~0.01ms per call |
| .new() in _process | Search: `.new()` in frame functions | Use object pooling | Eliminates GC pressure |
| Heavy _process | Count lines >15 | Move to signals/timers | Reduces per-frame load |
| Physics + AI | AI in `_physics_process` | Separate with timer | 5-10x reduction |
| Uncached settings | `ProjectSettings.get()` in loop | Cache in `_ready` | One-time cost |
**Problem:** Technical debt accumulates, harder to fix later
**Fix:** Profile early and often, fix bottlenecks as they appear
**Problem:** Optimizing code that isn't a bottleneck
**Fix:** Always profile first, focus on top time consumers
**Problem:** Spending hours to save 0.001ms
**Fix:** Target functions >0.1ms, ignore the rest
**Problem:** Assuming optimization worked without measuring
**Fix:** Always run profiler before and after
**Problem:** Debug builds have different performance characteristics
**Fix:** Profile release builds for accurate data
Leave a review
No reviews yet. Be the first to review this skill!
# Download SKILL.md from killerskills.ai/api/skills/godot-profile-performance/raw