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

install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
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"
manifest: skills/data/godot-profile-performance/SKILL.md
source content

Godot 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
  • _process
    functions contain complex logic
  • 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:

  • .new()
    in
    _process
    or
    _physics_process
  • .instantiate()
    in frame callbacks
  • add_child()
    or
    remove_child()
    in loops

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:

  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:

  • <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
  • _physics_process
    doing non-physics work

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

  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

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%
  • _process
    function line count <15 lines
  • Zero
    get_node()
    calls in
    _process
    or
    _physics_process
  • Zero
    .new()
    or
    .instantiate()
    calls in frame callbacks
  • 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
    _ready()
    or
    @onready
  • Complex logic moved to signals or timers
  • Object pooling used for frequent instantiations
  • _physics_process
    contains only physics-related code
  • Profiler data shows improvement vs baseline

Validation Steps

  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

Quick Reference

PatternDetectionFixImpact
get_node() in _processSearch:
get_node
after
_process
Cache in
@onready
~0.01ms per call
.new() in _processSearch:
.new()
in frame functions
Use object poolingEliminates GC pressure
Heavy _processCount lines >15Move to signals/timersReduces per-frame load
Physics + AIAI in
_physics_process
Separate with timer5-10x reduction
Uncached settings
ProjectSettings.get()
in loop
Cache in
_ready
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