Claude-skill-registry godot-profile-performance
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
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/godot-profile-performance" ~/.claude/skills/majiayu000-claude-skill-registry-godot-profile-performance && rm -rf "$T"
skills/data/godot-profile-performance/SKILL.mdGodot Performance Profiler
Overview
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.
When to Use
Use when:
- Frame rate drops or inconsistent performance
- CPU usage is unexpectedly high
- Game stutters during gameplay
functions contain complex logic_process- Memory usage grows over time
- Before releasing a game or major update
Don't use for:
- Network/server performance issues (use server profiling tools)
- GPU/shader optimization (use Godot's GPU profiler)
- Physics simulation tuning (use Godot's physics debug tools)
Detection Patterns
Heavy _process Functions
Trigger: Functions with >15 lines of code in
_process or _physics_process
Detection:
# Count lines in process functions rg "^func _process" -A 50 --glob "*.gd" | wc -l
Thresholds:
- Warning: >15 lines
- Critical: >30 lines
get_node() in Loops
Pattern:
# ❌ BAD: get_node() in _process func _process(delta): get_node("UI/HealthBar").value = health # Called every frame! get_node("Player").position = position
Detection regex:
func _process.*\n(?:.*\n)*?\s+get_node\(
Instantiation in _process
Pattern:
# ❌ BAD: Creating objects every frame func _process(delta): var bullet = Bullet.new() # Memory churn! add_child(bullet)
Detection keywords:
in.new()
or_process_physics_process
in frame callbacks.instantiate()
oradd_child()
in loopsremove_child()
Complex Operations in _physics_process
Anti-patterns:
- Heavy pathfinding calculations
- Complex AI state machines
- Large array operations
- File I/O or network calls
Analysis Procedures
Frame Time Analysis
Steps:
- Enable Godot profiler (Debug > Profiler)
- Run game for 60 seconds of typical gameplay
- Identify functions with high "Time (ms)" values
- Sort by "Time %" to find top offenders
- Look for functions >0.1ms per frame
Interpretation:
- <0.01ms: Excellent
- 0.01-0.05ms: Good
- 0.05-0.1ms: Acceptable
-
0.1ms: Needs optimization
Memory Usage Detection
Indicators:
- Continuous growth in Memory monitor
- Frequent garbage collection spikes
- Node count increasing over time
Detection:
# Check for object creation in process functions rg "\.new\(\)|instantiate\(\)" -B 5 -A 2 --glob "*.gd" | \ rg -A 10 "func _process|func _physics_process"
Draw Call Optimization
Check:
- Godot's "Monitors" tab > "Draw Calls"
- Each unique material = additional draw call
- GPU skinning vs CPU skinning
Optimization targets:
- <100 draw calls for 2D games
- <500 draw calls for simple 3D
- Use texture atlases to reduce material switches
Physics Performance
Red flags:
- High collision shape complexity
- Too many rigid bodies (>100)
- Complex polygon collisions
doing non-physics work_physics_process
Optimization Suggestions
Cache Node References
Before:
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:
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)
Move Initialization to _ready
Before:
func _process(delta): var gravity = ProjectSettings.get("physics/2d/default_gravity") velocity.y += gravity * delta
After:
var gravity func _ready(): gravity = ProjectSettings.get("physics/2d/default_gravity") func _process(delta): velocity.y += gravity * delta
Object Pooling for Bullets/Particles
Before:
func shoot(): var bullet = BulletScene.instantiate() bullet.position = global_position get_parent().add_child(bullet)
After:
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
Signal-Based Updates
Before:
func _process(delta): # Checking every frame if health changed if health != previous_health: update_health_bar() previous_health = health
After:
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)
Batch Array Operations
Before:
func _process(delta): for enemy in enemies: if enemy.position.distance_to(player.position) < 100: enemy.target_player()
After:
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()
Godot Profiler Integration
Enabling the Profiler
- Run game with Debug > Start with Profiler
- Or click the "Profiler" tab in the bottom panel while game runs
- Enable specific monitors:
- CPU Time
- Function Time
- Node Count
- Memory
- Draw Calls
Key Metrics to Monitor
Frame Time (ms):
- Shows total time per frame
- Target: <16.67ms for 60 FPS, <33.33ms for 30 FPS
- Spikes indicate hitches
Function Breakdown:
- Lists all functions sorted by time
- Look for
,_process
,_physics_process_draw - Click function name to see callers
Memory Monitor:
- Watch for continuous growth
- Sudden spikes indicate allocations
- Plateaus followed by drops = garbage collection
Profiling Workflow
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
Examples
Example 1: UI Controller Optimization
Problem:
# ui_controller.gd 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:
# ui_controller.gd 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)
Example 2: Enemy Spawner Fix
Problem:
# spawner.gd 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:
# spawner.gd 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
Example 3: Physics Optimization
Problem:
# player.gd 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:
# player.gd 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%
Success Criteria
A performance optimization is successful when:
Quantitative Metrics
- Target function frame time reduced by >50%
-
function line count <15 lines_process - Zero
calls inget_node()
or_process_physics_process - Zero
or.new()
calls in frame callbacks.instantiate() - Profiler "Time %" for top 3 functions <30% combined
- Frame time stays <16.67ms for 60 FPS target
- Memory usage stable (no continuous growth)
Qualitative Checks
- Node references cached in
or_ready()@onready - Complex logic moved to signals or timers
- Object pooling used for frequent instantiations
-
contains only physics-related code_physics_process - Profiler data shows improvement vs baseline
Validation Steps
- Profile before: Record baseline metrics
- Apply optimization: Make targeted changes
- Profile after: Compare metrics
- Verify in-game: Test actual gameplay feels smoother
- Check edge cases: Ensure optimization works in all scenarios
Quick Reference
| Pattern | Detection | Fix | Impact |
|---|---|---|---|
| get_node() in _process | Search: after | Cache in | ~0.01ms per call |
| .new() in _process | Search: 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 | Separate with timer | 5-10x reduction |
| Uncached settings | in loop | Cache in | One-time cost |
Common Mistakes
Mistake: "I'll optimize later"
Problem: Technical debt accumulates, harder to fix later Fix: Profile early and often, fix bottlenecks as they appear
Mistake: Premature optimization
Problem: Optimizing code that isn't a bottleneck Fix: Always profile first, focus on top time consumers
Mistake: Micro-optimizations
Problem: Spending hours to save 0.001ms Fix: Target functions >0.1ms, ignore the rest
Mistake: Not validating improvements
Problem: Assuming optimization worked without measuring Fix: Always run profiler before and after
Mistake: Optimizing release builds only
Problem: Debug builds have different performance characteristics Fix: Profile release builds for accurate data