Game Performance skill for Claude Code

Hit the frame budget with captures, not hunches. Claude sets a frame budget for the target device, builds a repeatable benchmark, and classifies the game as CPU, render-thread or GPU bound before touching code. It profiles with the right engine and platform tools, fixes the largest cost first, hunts periodic GC and shader-compile hitches separately, and reports p95 frame time and 1% lows from real captures.

  • namegame-performance
  • size170 lines · ≈2,400 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Diagnoses and fixes game frame-rate problems against an explicit frame budget, deciding CPU-bound vs GPU-bound first, then profiling with Unity Profiler, Unreal Insights and stat commands, Godot profilers, RenderDoc, PIX, or PresentMon, and fixing draw calls, overdraw, GC spikes, physics and script costs, shader hitches, streaming stalls, and mobile thermal throttling. Use when a game stutters, drops frames, hitches periodically, runs hot on phones, misses 30, 60, 90, or 120 fps targets, or when analyzing frame-time captures.

Try asking

  • The game hitches every few seconds on Android, here is a frame-time capture
  • We are at 45 fps on Steam Deck and need a locked 60, figure out what is bound
  • Draw calls are at 3,000 in our city scene, cut them without losing visuals

Use it when

  • A game stutters, hitches or misses its fps target
  • Deciding whether CPU, render thread or GPU is the bottleneck
  • Analyzing frame-time captures from PresentMon or engine logs
  • Mobile builds throttle or run hot over long sessions

Skip it when

How to check the result

The same benchmark on the same device shows p95 frame time within budget and 1% lows above 90% of target, and the profiler confirms the targeted cost shrank.

The common mistake it prevents

Profiling in the editor or judging by average FPS, which hides the periodic spikes and frame-time variance that players actually feel.

~/.claude/skills/game-performance/SKILL.md

--- name: game-performance description: Diagnoses and fixes game frame-rate problems against an explicit frame budget, deciding CPU-bound vs GPU-bound first, then profiling with… license: MIT ---

Done well means: there is a target platform and frame budget, every claim is backed by a capture from a development build on that hardware, the bottleneck (CPU main thread, render thread, or GPU) is identified before anything changes, the largest cost is fixed first, and before/after numbers include p95 frame time and 1% lows, not just average FPS.

Ground rules

  • Never optimize from the editor. Editor overhead, debug checks, and a desktop GPU hide or invent problems. Profile development builds on the slowest target device.
  • Make the test repeatable: a benchmark scene, a recorded input replay, or a scripted camera path, same duration, same settings, device cooled down.
  • Average FPS hides stutter. Track p95/p99 frame time and 1%/0.1% lows; players feel frame-time variance more than the mean.
  • One change per measurement. Keep a log of change -> before/after numbers.
  • Do not rewrite systems (ECS, jobs, custom renderer) until a capture shows that system is the bottleneck.

Frame budgets

TargetBudget per frameTypical split (CPU game / render / GPU)
30 fps (mobile, console cinematic)33.3 msLeave ~10-20% headroom for spikes and thermal
60 fps16.67 mse.g. game 6-8 ms, render thread 4-6 ms, GPU 14 ms (parallel)
90 fps (VR)11.1 msMissed frames cause reprojection and discomfort
120 fps8.33 ms

CPU threads and GPU run in parallel; the frame takes as long as the slowest of them.

Workflow

- [ ] 1. Write the target: device, resolution, quality preset, fps
- [ ] 2. Build a repeatable benchmark; capture baseline frame times (scripts/frame_stats.py)
- [ ] 3. Classify: CPU game thread, render thread, or GPU bound (table below)
- [ ] 4. Profile the bound unit with the engine/platform profiler; find the top 3 costs
- [ ] 5. Fix the largest cost; re-capture with the same benchmark
- [ ] 6. Separately hunt spikes: GC, loading, shader compiles, instantiation
- [ ] 7. Sustained test on mobile (20-30 min) for thermal throttling
- [ ] 8. Write the report (template below)

Find the bottleneck

TestInterpretation
Unreal stat unit: compare Game, Draw, GPU, RHITHighest value near the frame time is the bound
Unity Profiler: CPU main thread vs render thread vs GPU module; “Gfx.WaitForPresent” on main threadMain thread waiting on present usually means GPU-bound
Godot Monitors (Time > Process/Physics) and Visual Profiler (CPU vs GPU per frame)Same split
Drop render resolution to 50%FPS rises a lot -> GPU fill-rate bound; unchanged -> CPU bound
Disable rendering of the scene but keep simulationFPS rises -> render/GPU; unchanged -> game logic

Tools

Engine/platformTools
UnityProfiler (Deep Profile only for short sessions), Profile Analyzer for comparing captures, Frame Debugger, Memory Profiler package, ProfilerMarker for custom scopes
UnrealUnreal Insights (-trace=default or -trace=cpu,gpu,frame), stat unit, stat gpu, ProfileGPU, stat scenerendering, stat game
GodotDebugger > Profiler, Visual Profiler, Monitors; custom monitors via Performance.add_custom_monitor
GPU frame captureRenderDoc, PIX (Windows/Xbox), Xcode Metal debugger (Apple), Android GPU Inspector, Snapdragon Profiler
CPU samplingSuperluminal, Tracy, Instruments, VTune, perf
Frame pacing capturesPresentMon / CapFrameX (Windows), engine frame-time logs
Web gamesChrome Performance panel, performance.now() frame logs, Spector.js

Analyze an exported frame-time capture:

python3 scripts/frame_stats.py capture.csv --target-fps 60          # PresentMon-style CSV
python3 scripts/frame_stats.py frames.txt --target-fps 30            # one ms value per line
python3 scripts/frame_stats.py log.csv --target-fps 60 --column gpu_ms

It auto-detects common column names (MsBetweenPresents, FrameTime, frame_time_ms, ms), prints average FPS, p50/p95/p99, 1% and 0.1% lows, frames over budget, the worst frames with timestamps, whether spikes recur at a regular interval (GC, autosave, streaming), and exits 1 if p95 exceeds the budget or the 1% low is under 90% of target.

CPU-bound fixes, by usual size of win

CostFix
Garbage collection spikes (C#, GDScript, JS, Lua)Zero steady-state allocation: pool objects, reuse buffers, no per-frame LINQ/closures/string building; Unity incremental GC on
Thousands of per-object update callbacksOne manager iterating arrays; disable updates on idle objects; tick far objects less often
PhysicsCollision layer matrix, primitive colliders, fewer active bodies, sleep thresholds, lower fixed rate if gameplay allows
AnimationCulling (only animate visible), lower update rate for distant characters, GPU skinning
AI and pathfindingTime-slice requests, AI LOD
UI rebuildsSplit static and dynamic canvases (Unity), avoid layout thrash, update text only on change
Hot scripting loopsMove to native code, Burst jobs (Unity), C++ (Unreal), GDExtension or C# (Godot)
Logging in shipping buildsStrip debug logs; string formatting allocates
Render thread: too many draw callsSee GPU table: batching and instancing reduce CPU submission too

GPU-bound fixes

CostFix
Too many draw calls / state changesInstancing, SRP Batcher and GPU Resident Drawer (Unity 6 URP/HDRP), static batching, texture atlases, merged meshes; Nanite for dense static geometry (UE5)
Overdraw (particles, foliage, transparent UI)Fewer and smaller particles, cutout instead of blended, trim transparent quads, check overdraw view modes
Fill rate / resolutionDynamic resolution, upscalers (DLSS, FSR, XeSS, TSR, MetalFX), lower internal resolution on mobile
Expensive shadersSimplify fragment work, fewer texture samples, reduce shader variants, lower precision on mobile
ShadowsFewer shadow-casting lights, shorter shadow distance, fewer cascades, baked lighting for static
Post-processingDrop or downsample bloom, SSR, SSAO, motion blur on low tiers
GeometryLODs, impostors for distant objects, occlusion culling, frustum culling of small objects

Spikes and hitches

SignatureCauseFix
Regular spike every few secondsGC, autosave, telemetry flush, timer systemRemove allocations, spread work across frames
Spike first time an effect or material appearsShader/PSO compilationWarm up or precache PSOs (UE5 PSO precaching/caching; Unity shader variant collections and the Unity 6 PSO tracing workflow), prewarm effects in loading screens
Spike on spawnInstantiate + component init + allocationPooling, pre-instantiate during loads
Spike when entering new areasSynchronous asset loads or streamingAsync loading, smaller streaming cells, preload ahead of the player
Stutter with good average FPSFrame pacing, vsync misses, uneven frame deliveryCap frame rate to a divisor of refresh rate, use platform frame pacing (Android Frame Pacing library), check PresentMon pacing

Mobile specifics

  • Thermal throttling: performance after 20-30 minutes is the real performance. Target 30 or 60 fps with headroom rather than an uncapped rate that heats the device.
  • Set the frame rate explicitly (Unity Application.targetFrameRate; Unreal t.MaxFPS; Godot Engine.max_fps); mobile defaults may cap at 30.
  • Tile-based GPUs: avoid unnecessary render target switches and full-screen passes; alpha test and discard disable early depth optimizations.
  • Use ASTC texture compression and mipmaps; keep texture memory inside the device budget to avoid OS kills.
  • Test on a low-end device from the target market, not only the developer’s flagship.

Gotchas

  • Profiling with Deep Profile or full instrumentation on inflates script costs; confirm with a sampling capture before acting.
  • VSync hides headroom: a game locked at 60 might be using 15.9 of 16.6 ms. Measure with vsync off or read raw CPU/GPU times.
  • “Optimized” code that moves work to another frame without reducing it can increase latency; check input-to-photon delay for action games.
  • Caching component lookups matters only if they appear in the capture; do not micro-optimize code that costs 0.01 ms.
  • Debug builds of native code can be 5-10x slower; profile Development (Unreal) or release-with-symbols builds.

Report template

**Target:** Pixel 7a, 1080p, Medium preset, 60 fps (16.67 ms)
**Benchmark:** Level 3 replay, 90 s, device at room temperature
| Metric | Before | After |
|---|---|---|
| Average FPS | 51.2 | 61.8 (capped 60) |
| p95 frame time | 24.1 ms | 16.2 ms |
| 1% low | 31 fps | 55 fps |
| Frames over budget | 38% | 2.1% |
**Bottleneck:** GPU (resolution test +70% fps); overdraw from smoke particles 6.2 ms.
**Changes:** particle count halved + cutout shader (-4.1 ms GPU); pooled projectiles
removed 2 KB/frame GC and the 3.5 s periodic spike.
**Remaining risks:** thermal drop to 48 fps after 25 min; next: dynamic resolution.

Verification

  1. Same benchmark, same device, before and after: p95 within budget and 1% low at least 90% of target (frame_stats.py exits 0).
  2. The fix shows up in the profiler where expected (the targeted marker or pass shrank).
  3. No new spikes: worst-frame list and periodic-pattern check are clean.
  4. Mobile: a 20-30 minute sustained run holds the target or degrades only within the agreed margin.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/frame_stats.py 141 lines · 6.0 KB
#!/usr/bin/env python3
"""Frame-time statistics and spike finder for game captures (stdlib only).

Input: a CSV with a frame-time column in milliseconds (PresentMon/CapFrameX style
"MsBetweenPresents" or "FrameTime", or any column named via --column), or a plain text
file with one frame time per line. Use - for stdin.

  python3 frame_stats.py capture.csv --target-fps 60
  python3 frame_stats.py frames.txt --target-fps 30 --column ms
  python3 frame_stats.py capture.csv --target-fps 60 --fps-input   # column holds FPS, not ms

Reports average FPS, p50/p95/p99 frame time, 1% and 0.1% lows, share of frames over
budget, the worst spikes, and whether spikes recur at a regular interval (a common
signature of garbage collection, streaming, or a periodic system).

Exit status: 0 = p95 within budget and 1% low >= 90% of target, 1 = budget missed,
2 = bad input.
"""
from __future__ import annotations

import argparse
import csv
import io
import statistics
import sys

KNOWN = ["msbetweenpresents", "frametime", "frame_time_ms", "frame_time", "frametime_ms", "ms", "cpu_ms", "gpu_ms"]


def load(text: str, column: str | None, fps_input: bool) -> list[float]:
    lines = [l for l in text.splitlines() if l.strip() and not l.lstrip().startswith("//")]
    if not lines:
        raise ValueError("no data")
    try:
        float(lines[0].split(",")[0])
        has_header = False
    except ValueError:
        has_header = True
    values: list[float] = []
    if has_header:
        reader = csv.DictReader(io.StringIO("\n".join(lines)))
        fields = {f.strip().lower(): f for f in (reader.fieldnames or [])}
        key = None
        if column:
            key = fields.get(column.lower())
            if key is None:
                raise ValueError(f"column '{column}' not found; have: {', '.join(reader.fieldnames or [])}")
        else:
            for k in KNOWN:
                if k in fields:
                    key = fields[k]
                    break
            if key is None:
                raise ValueError(f"no frame-time column found; pass --column. Columns: {', '.join(reader.fieldnames or [])}")
        for row in reader:
            v = (row.get(key) or "").strip()
            if v:
                values.append(float(v))
    else:
        values = [float(l.split(",")[0]) for l in lines]
    if fps_input:
        values = [1000.0 / v for v in values if v > 0]
    values = [v for v in values if v > 0]
    if len(values) < 10:
        raise ValueError(f"only {len(values)} frames; capture at least a few hundred")
    return values


def pct(sorted_vals: list[float], p: float) -> float:
    idx = min(len(sorted_vals) - 1, max(0, round(p / 100 * (len(sorted_vals) - 1))))
    return sorted_vals[idx]


def low_fps(sorted_vals: list[float], fraction: float) -> float:
    n = max(1, int(len(sorted_vals) * fraction))
    worst = sorted_vals[-n:]
    return 1000.0 / statistics.fmean(worst)


def main() -> int:
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("file", help="CSV or text file, or - for stdin")
    p.add_argument("--target-fps", type=float, default=60.0)
    p.add_argument("--column", help="frame-time column name (case-insensitive)")
    p.add_argument("--fps-input", action="store_true", help="values are FPS instead of milliseconds")
    p.add_argument("--spike-factor", type=float, default=2.0, help="spike = frame > factor x median (default 2)")
    p.add_argument("--top", type=int, default=5, help="number of worst frames to list")
    a = p.parse_args()
    try:
        text = sys.stdin.read() if a.file == "-" else open(a.file, encoding="utf-8-sig").read()
        ft = load(text, a.column, a.fps_input)
    except (OSError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2

    budget = 1000.0 / a.target_fps
    s = sorted(ft)
    total_s = sum(ft) / 1000.0
    median = statistics.median(ft)
    over = sum(1 for v in ft if v > budget)
    spikes = [i for i, v in enumerate(ft) if v > a.spike_factor * median]
    p95, p99 = pct(s, 95), pct(s, 99)
    low1, low01 = low_fps(s, 0.01), low_fps(s, 0.001)

    print(f"Frames: {len(ft)} over {total_s:.1f} s | target {a.target_fps:g} fps = {budget:.2f} ms budget")
    print(f"Average FPS: {len(ft) / total_s:.1f}   (mean frame {statistics.fmean(ft):.2f} ms)")
    print(f"Frame time p50 {median:.2f} ms | p95 {p95:.2f} ms | p99 {p99:.2f} ms | max {s[-1]:.2f} ms")
    print(f"1% low {low1:.1f} fps | 0.1% low {low01:.1f} fps")
    print(f"Over budget: {over} frames ({over / len(ft):.1%})")
    print(f"Spikes (> {a.spike_factor:g}x median = {a.spike_factor * median:.2f} ms): {len(spikes)}")
    worst = sorted(range(len(ft)), key=lambda i: ft[i], reverse=True)[: a.top]
    for i in sorted(worst):
        t = sum(ft[:i]) / 1000.0
        print(f"  frame {i:>6} at {t:7.2f} s: {ft[i]:.2f} ms")

    if len(spikes) >= 4:
        times, acc = [], 0.0
        idx = set(spikes)
        for i, v in enumerate(ft):
            if i in idx:
                times.append(acc)
            acc += v
        gaps = [b - a2 for a2, b in zip(times, times[1:])]
        mean_gap = statistics.fmean(gaps)
        cv = statistics.pstdev(gaps) / mean_gap if mean_gap else 1
        if cv < 0.25:
            print(f"Pattern: spikes recur every ~{mean_gap / 1000:.2f} s (low variance) -> suspect GC, "
                  f"periodic save/autosave, streaming tick, or a timer-driven system")
        else:
            print("Pattern: spikes are irregular -> suspect content-triggered work (spawns, loads, shader compiles)")
    jumps = sum(1 for x, y in zip(ft, ft[1:]) if abs(y - x) > budget)
    print(f"Frame-to-frame jumps > one budget: {jumps} (perceived stutter even when average FPS is fine)")

    ok = p95 <= budget and low1 >= 0.9 * a.target_fps
    print("RESULT: " + ("PASS" if ok else "FAIL") +
          f" (p95 {'<=' if p95 <= budget else '>'} budget, 1% low {low1:.1f} vs {0.9 * a.target_fps:.1f} required)")
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc