Game Performance Review
Purpose
Audit a game's runtime performance against concrete budgets: 16.6ms per frame at 60fps (6.9ms at 144fps), platform draw-call limits, GC-spike frequency, texture-memory footprint, object-pooling correctness, and asset-streaming stalls. Produce findings that name the system, the estimated frame-time cost, the fix, and the profiler metric required to confirm it — separating sub-hour quick wins from multi-day structural changes so the team can act in priority order.
When to use
- The game drops below its target frame rate on any supported platform.
- GC spikes cause hitches (visible as frame-time spikes in the profiler).
- Loading screens are too long or asset streaming stalls mid-gameplay.
- The game fails platform certification on memory limits or thermal throttling.
- A performance baseline is needed before a major feature addition.
When not to use
- The game runs well and the task is a general architecture review — use game-architecture-review.
- The frame drop is caused by a logic bug, not a budget issue — use gameplay-systems-review to find the root cause first.
- The issue is a rendering artifact (wrong color, missing geometry), not a timing problem.
Procedure
- Establish platform and target. Note platform (PC/console/mobile/WebGL), target frame rate (30/60/120fps), and target hardware. Compute the per-frame budget: 60fps = 16.6ms, with a typical split of ~8ms CPU / ~4ms GPU / ~4ms render thread.
- Locate profiler data or request it. Ask for a Unity Profiler, Godot profiler, Unreal Insights, or RenderDoc capture. With none available, identify the suspected hotspot from code and note that a profiler capture is required to confirm.
- Review draw-call count. Identify total draw calls per frame. Mobile budget under ~100; mid-range PC/console under ~300. Check dynamic-batching eligibility (same material, no rigidbody), confirm static batching for non-moving geometry, and flag GPU-instancing candidates (same mesh + material, many instances).
- Review texture memory. Identify atlases and compression formats. Mobile needs ETC2 (Android) or ASTC; desktop needs DXT/BC. Flag uncompressed textures in production builds, confirm mipmaps for all 3D textures, and flag textures larger than their on-screen coverage warrants.
- Review object pooling. Identify frequently spawned/destroyed objects (projectiles, particles, enemies). Confirm a pool exists for each, that pools pre-warm during loading (not on first spawn), and that pool size avoids both runtime allocation (too small) and memory waste (too large).
- Review GC-allocation hotspots. Search for per-frame heap allocations: string concatenation in
Update, LINQ in hot paths, GetComponent<T>() without caching, new List<T>() in loops (C#); per-frame Array/Dictionary construction (GDScript). Each is a future GC spike.
- Review Update-loop cost. Count scripts with
Update/_process. State-poll-only scripts ("is the player near?") should use a timed coroutine instead. Flag expensive per-frame ops: FindObjectsByType, GetComponentsInChildren, physics queries with no layer mask.
- Review asset loading. Confirm large assets load asynchronously (Addressables,
ResourceLoader.load_threaded_request) with completion callbacks. Flag Resources.Load in hot paths (synchronous, uncached).
- Review particles and VFX. Confirm a max particle-count cap, that off-screen systems are stopped (not left playing), and that particles do not use uncompressed textures.
- Review audio CPU cost. Verify SFX reuse pooled players rather than instantiating per sound, that audio buses handle compression/limiting, and that sample rates are sane (44100Hz music, 22050Hz acceptable mobile SFX).
- Review overdraw and shadows. Check for stacked full-screen transparent layers (mobile GPU killer) and real-time shadow casting on minor props, both common GPU-bound causes.
- Summarize findings with estimated frame time saved and implementation effort per fix, noting that any number not from a target-hardware player build is a hypothesis.
Concrete checks
- Frame budget established: target fps and CPU/GPU split defined.
- Draw calls within platform budget; batching and instancing evaluated.
- Texture compression platform-appropriate; mipmaps on; no oversized textures.
- Object pools for projectiles/particles/enemies; pre-warmed; correctly sized.
- No per-frame heap allocations in C# hot paths (strings, LINQ, new collections).
- Update loop free of
FindObjects/GetComponentsInChildren per frame; polls use coroutines.
- Asset loading async with callbacks; no
Resources.Load in hot paths.
- Particles capped, stopped off-screen, compressed textures.
- Audio uses pooled players, correct sample rates, configured buses.
- Mobile thermal: sustained load does not trigger CPU throttling after ~5 minutes.
- Overdraw is bounded: no stack of full-screen transparent layers redrawing every pixel.
- Physics tick rate matches gameplay need, not an arbitrarily high default.
- Shadow casting is limited to objects that need it, not every prop.
- Assets load per-level and release on unload, not all preloaded at boot.
- All cost numbers come from a development player build on target hardware, not the editor.
Commands
# Per-frame string allocation (GC spikes) in Update/_process
rg -n "(Update|_process)\(" -A 25 --type-add 'gd:*.gd' -tgd -tcs \
| rg '"\s*\+|\+\s*"|\$"|str\(|String\.Format'
# LINQ and new-collection allocations in hot paths (C#)
rg -n "(Update|FixedUpdate)\(" -A 25 -tcs | rg "\.Where\(|\.Select\(|\.FirstOrDefault\(|new List<|new Dictionary<"
# Uncached GetComponent / expensive lookups called every frame
rg -n "(Update|FixedUpdate)\(" -A 25 -tcs | rg "GetComponent|FindObjectsByType|GetComponentsInChildren|GameObject\.Find"
# Synchronous resource loads (block the main thread)
rg -n "Resources\.Load|ResourceLoader\.load\(|preload\(|GD\.load\(" --type-add 'gd:*.gd' -tgd -tcs
# Async load WITHOUT a completion-status check (crash / stall risk)
rg -n "load_threaded_request|LoadSceneAsync|InstantiateAsync" -A 8 --type-add 'gd:*.gd' -tgd -tcs \
| rg -v "load_threaded_get_status|isDone|completed|THREAD_LOAD_LOADED"
# Particle systems and whether a max-count cap exists
rg -n "GPUParticles|CPUParticles|ParticleSystem|amount\s*=|maxParticles" --type-add 'gd:*.gd' -tgd -tcs
# Texture import settings — spot uncompressed / oversized assets
rg -n "compress|Compression|textureFormat|mipmaps|max_size" -i $(find . -name '*.import' -o -name '*.meta') 2>/dev/null | head
# Largest texture/audio assets on disk (over-budget candidates)
find . \( -name '*.png' -o -name '*.tga' -o -name '*.wav' \) -exec du -h {} + | sort -rh | head -15
# Audio import mode: Decompress-on-Load (RAM heavy) vs Streaming for long clips
rg -n "loadType|Decompress|Streaming|StreamingAssets|loadInBackground" -i $(find . -name '*.meta') 2>/dev/null | head
# Scripts with an Update/_process that could be event-driven instead
rg -l "(void Update\(\)|func _process\()" --type-add 'gd:*.gd' -tgd -tcs | wc -l
# Engine profiler / capture entry points (request a capture if missing)
godot --headless --print-fps 2>/dev/null | tail -5 # quick frame-time sample
# Unity: open Window > Analysis > Profiler ; capture a 'Player' build, not the editor
# Unreal: 'stat unit', 'stat gpu', and Unreal Insights trace for frame breakdown
# Overdraw / transparency layers (GPU-bound on mobile)
rg -n "transparent|blend_mode|alpha|CanvasLayer|Overlay|fog" -i --type-add 'gd:*.gd' -tgd -tcs | head
# Shadow casting on minor objects
rg -n "shadow|cast_shadow|ShadowCastingMode|ReceiveShadows" -i --type-add 'gd:*.gd' -tgd -tcs | head
# Physics tick rate vs need
rg -n "physics_ticks_per_second|fixedDeltaTime|Time\.fixedDeltaTime|MaxPhysicsDelta" --type-add 'gd:*.gd' -tgd -tcs project.godot 2>/dev/null
# Preload-everything-at-boot vs per-level load
rg -n "preload\(|Resources\.LoadAll|LoadAll<|AddressableAssets" --type-add 'gd:*.gd' -tgd -tcs | wc -l
Per-frame budget (where the time goes)
| Target |
Total frame |
Typical CPU / GPU / render split |
| 30 fps |
33.3 ms |
16 / 10 / 7 |
| 60 fps |
16.6 ms |
8 / 4 / 4 |
| 120 fps |
8.3 ms |
4 / 2 / 2 |
| 144 fps |
6.9 ms |
3.5 / 1.7 / 1.7 |
Platform budgets (rules of thumb)
| Resource |
Mobile |
Mid PC/console |
Note |
| Draw calls / frame |
< 100 |
< 300 |
batch + instance to reduce |
| Texture compression |
ASTC / ETC2 |
BC/DXT |
never ship uncompressed |
| Triangles visible |
100–300k |
1–3M |
LOD distant meshes |
| Audio voices |
16–24 |
32–64 |
pool players, cap concurrency |
Common issues & anti-patterns
- String concatenation in Update.
Debug.Log("pos: " + transform.position) every frame allocates a string. Strip it from release with conditional compilation.
- LINQ in hot paths.
enemies.Where(e => e.IsAlive).FirstOrDefault() allocates an iterator each call. Replace with a manual foreach and early return.
- No GPU instancing on repeated meshes. A forest of 500 identical trees drawn with 500 calls. Enable instancing on the material to collapse it to a handful.
GetComponent<T>() per frame. Calling it in Update instead of caching in Awake is invisible at one entity and crushing at a thousand.
- Particle system with no max count. One frame of many simultaneous explosions exhausts the particle budget and spikes 100ms. Cap the count.
- Synchronous scene load on the main thread.
SceneManager.LoadScene blocks for the full load. Use LoadSceneAsync behind a loading screen.
- Oversized audio in memory. A 5-minute track set to Decompress-on-Load allocates ~210MB of PCM. Stream music instead.
- Update on distant idle NPCs. 200 NPCs all running
Update when 180 are far from the player. Disable Update by distance-based LOD.
- Overdraw from full-screen transparent layers. Stacked alpha-blended UI or fog quads redraw every pixel several times, GPU-bound on mobile. Reduce layers or use opaque where possible.
- Physics tick too high. Running fixed physics at 120Hz for a game that only needs 50Hz doubles CPU physics cost for no gain. Match the tick to the gameplay need.
- Shadow-casting on everything. Every small prop casting real-time shadows multiplies draw calls. Disable shadow casting on minor objects.
- Loading all assets up front. Preloading the whole game into memory at boot blows the mobile memory budget. Load per-level and release on unload.
- Profiling the editor, not a build. Editor overhead inflates every number. Always profile a development player build on target hardware.
Required output
Return a structured report with:
- Platform, target fps, and the per-frame budget breakdown.
- Findings table:
System | Estimated Cost | Severity | Issue | Fix | Profiler metric to verify.
- Quick wins (under ~1 hour each) separated from structural changes (over ~1 day each).
- A profiler-evidence checklist: what to capture to confirm each finding.
Safety
- Do not delete assets, textures, or audio files.
- Do not modify project settings (quality levels, graphics tiers) without listing every change for review.
- Do not trigger builds, profiling sessions, or device deployments.
- Treat any cost estimate without a profiler capture as a hypothesis, not a confirmed number.
Completion criteria
Done means the platform budget is stated, draw calls / textures / pooling / GC / Update / asset-loading / particles / audio are each assessed from evidence, every finding has an estimated cost, a fix, and the profiler metric to confirm it, and quick wins are separated from structural work.
1---2name: game-performance-review3description: Use when you need to review frame budget, asset loading, rendering, memory, and interaction performance.4---56# Game Performance Review78## Purpose9Audit a game's runtime performance against concrete budgets: 16.6ms per frame at 60fps (6.9ms at 144fps), platform draw-call limits, GC-spike frequency, texture-memory footprint, object-pooling correctness, and asset-streaming stalls. Produce findings that name the system, the estimated frame-time cost, the fix, and the profiler metric required to confirm it — separating sub-hour quick wins from multi-day structural changes so the team can act in priority order.1011## When to use12- The game drops below its target frame rate on any supported platform.13- GC spikes cause hitches (visible as frame-time spikes in the profiler).14- Loading screens are too long or asset streaming stalls mid-gameplay.15- The game fails platform certification on memory limits or thermal throttling.16- A performance baseline is needed before a major feature addition.1718## When not to use19- The game runs well and the task is a general architecture review — use game-architecture-review.20- The frame drop is caused by a logic bug, not a budget issue — use gameplay-systems-review to find the root cause first.21- The issue is a rendering artifact (wrong color, missing geometry), not a timing problem.2223## Procedure241. **Establish platform and target.** Note platform (PC/console/mobile/WebGL), target frame rate (30/60/120fps), and target hardware. Compute the per-frame budget: 60fps = 16.6ms, with a typical split of ~8ms CPU / ~4ms GPU / ~4ms render thread.252. **Locate profiler data or request it.** Ask for a Unity Profiler, Godot profiler, Unreal Insights, or RenderDoc capture. With none available, identify the suspected hotspot from code and note that a profiler capture is required to confirm.263. **Review draw-call count.** Identify total draw calls per frame. Mobile budget under ~100; mid-range PC/console under ~300. Check dynamic-batching eligibility (same material, no rigidbody), confirm static batching for non-moving geometry, and flag GPU-instancing candidates (same mesh + material, many instances).274. **Review texture memory.** Identify atlases and compression formats. Mobile needs ETC2 (Android) or ASTC; desktop needs DXT/BC. Flag uncompressed textures in production builds, confirm mipmaps for all 3D textures, and flag textures larger than their on-screen coverage warrants.285. **Review object pooling.** Identify frequently spawned/destroyed objects (projectiles, particles, enemies). Confirm a pool exists for each, that pools pre-warm during loading (not on first spawn), and that pool size avoids both runtime allocation (too small) and memory waste (too large).296. **Review GC-allocation hotspots.** Search for per-frame heap allocations: string concatenation in `Update`, LINQ in hot paths, `GetComponent<T>()` without caching, `new List<T>()` in loops (C#); per-frame Array/Dictionary construction (GDScript). Each is a future GC spike.307. **Review Update-loop cost.** Count scripts with `Update`/`_process`. State-poll-only scripts ("is the player near?") should use a timed coroutine instead. Flag expensive per-frame ops: `FindObjectsByType`, `GetComponentsInChildren`, physics queries with no layer mask.318. **Review asset loading.** Confirm large assets load asynchronously (Addressables, `ResourceLoader.load_threaded_request`) with completion callbacks. Flag `Resources.Load` in hot paths (synchronous, uncached).329. **Review particles and VFX.** Confirm a max particle-count cap, that off-screen systems are stopped (not left playing), and that particles do not use uncompressed textures.3310. **Review audio CPU cost.** Verify SFX reuse pooled players rather than instantiating per sound, that audio buses handle compression/limiting, and that sample rates are sane (44100Hz music, 22050Hz acceptable mobile SFX).3411. **Review overdraw and shadows.** Check for stacked full-screen transparent layers (mobile GPU killer) and real-time shadow casting on minor props, both common GPU-bound causes.3512. **Summarize findings** with estimated frame time saved and implementation effort per fix, noting that any number not from a target-hardware player build is a hypothesis.3637## Concrete checks38- Frame budget established: target fps and CPU/GPU split defined.39- Draw calls within platform budget; batching and instancing evaluated.40- Texture compression platform-appropriate; mipmaps on; no oversized textures.41- Object pools for projectiles/particles/enemies; pre-warmed; correctly sized.42- No per-frame heap allocations in C# hot paths (strings, LINQ, new collections).43- Update loop free of `FindObjects`/`GetComponentsInChildren` per frame; polls use coroutines.44- Asset loading async with callbacks; no `Resources.Load` in hot paths.45- Particles capped, stopped off-screen, compressed textures.46- Audio uses pooled players, correct sample rates, configured buses.47- Mobile thermal: sustained load does not trigger CPU throttling after ~5 minutes.48- Overdraw is bounded: no stack of full-screen transparent layers redrawing every pixel.49- Physics tick rate matches gameplay need, not an arbitrarily high default.50- Shadow casting is limited to objects that need it, not every prop.51- Assets load per-level and release on unload, not all preloaded at boot.52- All cost numbers come from a development player build on target hardware, not the editor.5354## Commands55```bash56# Per-frame string allocation (GC spikes) in Update/_process57rg -n "(Update|_process)\(" -A 25 --type-add 'gd:*.gd' -tgd -tcs \58 | rg '"\s*\+|\+\s*"|\$"|str\(|String\.Format'5960# LINQ and new-collection allocations in hot paths (C#)61rg -n "(Update|FixedUpdate)\(" -A 25 -tcs | rg "\.Where\(|\.Select\(|\.FirstOrDefault\(|new List<|new Dictionary<"6263# Uncached GetComponent / expensive lookups called every frame64rg -n "(Update|FixedUpdate)\(" -A 25 -tcs | rg "GetComponent|FindObjectsByType|GetComponentsInChildren|GameObject\.Find"6566# Synchronous resource loads (block the main thread)67rg -n "Resources\.Load|ResourceLoader\.load\(|preload\(|GD\.load\(" --type-add 'gd:*.gd' -tgd -tcs6869# Async load WITHOUT a completion-status check (crash / stall risk)70rg -n "load_threaded_request|LoadSceneAsync|InstantiateAsync" -A 8 --type-add 'gd:*.gd' -tgd -tcs \71 | rg -v "load_threaded_get_status|isDone|completed|THREAD_LOAD_LOADED"7273# Particle systems and whether a max-count cap exists74rg -n "GPUParticles|CPUParticles|ParticleSystem|amount\s*=|maxParticles" --type-add 'gd:*.gd' -tgd -tcs7576# Texture import settings — spot uncompressed / oversized assets77rg -n "compress|Compression|textureFormat|mipmaps|max_size" -i $(find . -name '*.import' -o -name '*.meta') 2>/dev/null | head78```79```bash80# Largest texture/audio assets on disk (over-budget candidates)81find . \( -name '*.png' -o -name '*.tga' -o -name '*.wav' \) -exec du -h {} + | sort -rh | head -158283# Audio import mode: Decompress-on-Load (RAM heavy) vs Streaming for long clips84rg -n "loadType|Decompress|Streaming|StreamingAssets|loadInBackground" -i $(find . -name '*.meta') 2>/dev/null | head8586# Scripts with an Update/_process that could be event-driven instead87rg -l "(void Update\(\)|func _process\()" --type-add 'gd:*.gd' -tgd -tcs | wc -l8889# Engine profiler / capture entry points (request a capture if missing)90godot --headless --print-fps 2>/dev/null | tail -5 # quick frame-time sample91# Unity: open Window > Analysis > Profiler ; capture a 'Player' build, not the editor92# Unreal: 'stat unit', 'stat gpu', and Unreal Insights trace for frame breakdown93```94```bash95# Overdraw / transparency layers (GPU-bound on mobile)96rg -n "transparent|blend_mode|alpha|CanvasLayer|Overlay|fog" -i --type-add 'gd:*.gd' -tgd -tcs | head9798# Shadow casting on minor objects99rg -n "shadow|cast_shadow|ShadowCastingMode|ReceiveShadows" -i --type-add 'gd:*.gd' -tgd -tcs | head100101# Physics tick rate vs need102rg -n "physics_ticks_per_second|fixedDeltaTime|Time\.fixedDeltaTime|MaxPhysicsDelta" --type-add 'gd:*.gd' -tgd -tcs project.godot 2>/dev/null103104# Preload-everything-at-boot vs per-level load105rg -n "preload\(|Resources\.LoadAll|LoadAll<|AddressableAssets" --type-add 'gd:*.gd' -tgd -tcs | wc -l106```107108## Per-frame budget (where the time goes)109| Target | Total frame | Typical CPU / GPU / render split |110|--------|-------------|----------------------------------|111| 30 fps | 33.3 ms | 16 / 10 / 7 |112| 60 fps | 16.6 ms | 8 / 4 / 4 |113| 120 fps | 8.3 ms | 4 / 2 / 2 |114| 144 fps | 6.9 ms | 3.5 / 1.7 / 1.7 |115116## Platform budgets (rules of thumb)117| Resource | Mobile | Mid PC/console | Note |118|----------|--------|----------------|------|119| Draw calls / frame | < 100 | < 300 | batch + instance to reduce |120| Texture compression | ASTC / ETC2 | BC/DXT | never ship uncompressed |121| Triangles visible | 100–300k | 1–3M | LOD distant meshes |122| Audio voices | 16–24 | 32–64 | pool players, cap concurrency |123124## Common issues & anti-patterns125- **String concatenation in Update.** `Debug.Log("pos: " + transform.position)` every frame allocates a string. Strip it from release with conditional compilation.126- **LINQ in hot paths.** `enemies.Where(e => e.IsAlive).FirstOrDefault()` allocates an iterator each call. Replace with a manual `foreach` and early return.127- **No GPU instancing on repeated meshes.** A forest of 500 identical trees drawn with 500 calls. Enable instancing on the material to collapse it to a handful.128- **`GetComponent<T>()` per frame.** Calling it in `Update` instead of caching in `Awake` is invisible at one entity and crushing at a thousand.129- **Particle system with no max count.** One frame of many simultaneous explosions exhausts the particle budget and spikes 100ms. Cap the count.130- **Synchronous scene load on the main thread.** `SceneManager.LoadScene` blocks for the full load. Use `LoadSceneAsync` behind a loading screen.131- **Oversized audio in memory.** A 5-minute track set to Decompress-on-Load allocates ~210MB of PCM. Stream music instead.132- **Update on distant idle NPCs.** 200 NPCs all running `Update` when 180 are far from the player. Disable Update by distance-based LOD.133- **Overdraw from full-screen transparent layers.** Stacked alpha-blended UI or fog quads redraw every pixel several times, GPU-bound on mobile. Reduce layers or use opaque where possible.134- **Physics tick too high.** Running fixed physics at 120Hz for a game that only needs 50Hz doubles CPU physics cost for no gain. Match the tick to the gameplay need.135- **Shadow-casting on everything.** Every small prop casting real-time shadows multiplies draw calls. Disable shadow casting on minor objects.136- **Loading all assets up front.** Preloading the whole game into memory at boot blows the mobile memory budget. Load per-level and release on unload.137- **Profiling the editor, not a build.** Editor overhead inflates every number. Always profile a development *player* build on target hardware.138139## Required output140Return a structured report with:1411. Platform, target fps, and the per-frame budget breakdown.1422. Findings table: `System | Estimated Cost | Severity | Issue | Fix | Profiler metric to verify`.1433. Quick wins (under ~1 hour each) separated from structural changes (over ~1 day each).1444. A profiler-evidence checklist: what to capture to confirm each finding.145146## Safety147- Do not delete assets, textures, or audio files.148- Do not modify project settings (quality levels, graphics tiers) without listing every change for review.149- Do not trigger builds, profiling sessions, or device deployments.150- Treat any cost estimate without a profiler capture as a hypothesis, not a confirmed number.151152## Completion criteria153Done means the platform budget is stated, draw calls / textures / pooling / GC / Update / asset-loading / particles / audio are each assessed from evidence, every finding has an estimated cost, a fix, and the profiler metric to confirm it, and quick wins are separated from structural work.