Why most indie performance work starts in the wrong place
Performance problems in indie games rarely show up as a single “slow function”. More often you get a feeling: stutter here, hitching there, battery drain in a port, or a frame time spike that ruins an otherwise smooth scene. The trap is to guess—then optimise the wrong thing—then end up with “fixed” code that’s still stuttering.
This post is a lightweight checklist you can run every time you investigate performance. The aim is to reduce guesswork, narrow the search quickly, and make changes that are easy to verify.
Step 1: Define what “bad” means (and when it happens)
Before you open a profiler, be specific. Write down:
- Target platform(s): PC only, Steam Deck, Switch, mobile?
- Symptom type: consistent low FPS, periodic spikes, input lag, audio glitches, camera jitter.
- Trigger: entering a room, spawning enemies, opening a UI panel, loading a save, switching scenes.
- Frequency: “once per 10 seconds”, “only after 5 minutes”, “every wave”.
If you can’t state the trigger, you’ll struggle to reproduce it—profiling becomes noise.
Step 2: Make a reproducible test scene (or test harness)
Optimisation is only as good as your ability to reproduce. You want a small, deterministic-ish scenario that hits the same slow path every time. Even if your game isn’t deterministic, you can still make the harness consistent by controlling:
- Camera position and movement pattern
- Spawn timings and counts
- Quality settings
- Whether you’ve warmed up caches/assets
A common indie pattern is to build a “performance room”: a simple level that spawns the worst-case mix of entities, runs UI interactions, and loops the same gameplay beat. It sounds dull, but it saves hours.
Step 3: Collect two kinds of evidence
Use profiling for both where time goes and why it spikes. Most engines expose some combination of:
- CPU profiling (function timings, job scheduling, scripts)
- GPU profiling (render passes, draw calls, shader cost)
- Frame breakdowns (render vs simulation vs waiting)
- Memory & allocations (garbage collection, allocator churn)
In practice, if you only do CPU profiling you’ll miss GPU stalls, and if you only do GPU profiling you’ll miss scripting allocations or physics spikes.
Step 4: Start with the simplest “top offenders” view
Don’t dive straight into micro-optimisations. First, identify the top contributors. A good workflow is:
- Run the profiler for long enough to capture the spike(s).
- Sort by total time (not just peak).
- Then sort by the specific event you care about (e.g., render pass time, GC time, physics step, script update).
If you’re seeing a spike, look for:
- Repeated allocations during the spike window
- Asset loading or shader compilation
- State changes that multiply draw calls
- Thread synchronisation (waiting on the GPU or job system)
Step 5: Check allocations and GC (even in “managed” engines)
Garbage collection and allocation churn are among the most common causes of periodic stutter. The tell is a sawtooth frame time pattern: fast frames, then a spike, then recovery.
Your checklist:
- Look for GC spikes aligned with the hitch.
- Identify per-frame allocations in hot update paths.
- Search for patterns like creating strings, lists, dictionaries, or arrays inside loops.
- Prefer reuse (object pooling, cached buffers) over “build then discard”.
You don’t need to eliminate every allocation—just stop the ones that happen in the spike window.
Step 6: Separate simulation cost from rendering cost
A quick sanity split helps. If the frame time is dominated by rendering, you’ll see long GPU timings or heavy draw/pass costs. If it’s dominated by simulation, you’ll see scripting, physics, animation, pathfinding, or ECS/job work.
Practical approach:
- Disable expensive visuals temporarily (effects, post-processing) and see if the spike remains.
- Freeze gameplay (stop spawns, pause AI) and see if the spike remains.
- Toggle UI updates (especially text/layout) to check for layout thrash.
Even without deep engine knowledge, this kind of A/B test often points you to the correct domain in minutes.
Step 7: Watch for “hidden multipliers”
Performance issues often arise from something that scales with the wrong factor. Examples:
- O(N²) behaviour from naive “check everyone against everyone” logic
- Repeated work per entity when you could batch or cache
- Per-frame string formatting in debug or UI
- Physics queries that accidentally run dozens of times per frame per entity
- State changes that prevent batching (materials, shaders, texture swaps)
When you see a “top offender” function, ask: does it scale with entity count, screen size, or the number of active objects? Then validate the hypothesis by running the test harness with different entity counts.
Step 8: Optimise for stability, not just the average
Indie players feel stutter more than they notice a small average FPS gain. So your goal isn’t “raise the mean”; it’s “reduce worst-case frame time”.
When you test changes:
- Record a short run (e.g., 60–120 seconds) with the same triggers.
- Compare p95/p99 frame times if your tooling supports it.
- Ensure you didn’t move the spike somewhere else (classic “optimised the CPU, now the GPU stalls”).
Keep changes small. One bottleneck fix per commit is easier to reason about than a week of bundled optimisations.
Step 9: Use version control and “perf branches”
It’s tempting to just “try things until it feels better”. Resist that. Instead:
- Create a dedicated branch for performance work.
- Make one measurable change per commit.
- Write a short note: what you changed, what you expected, and what you observed.
This prevents the situation where you can’t tell whether the improvement came from the new code, a different test run, or a background change.
Step 10: Know when to stop profiling and start engineering fixes
Profilers tell you where time goes. They don’t tell you the best fix. At some point you need to apply engineering patterns:
- Pooling for transient objects
- Caching for expensive computations
- Batching for render-side state changes
- Spatial partitioning for collision/queries
- Jobifying or moving heavy work off the main thread (where appropriate)
But do that after you’ve confirmed the target is real. The checklist is there to stop you from solving the wrong problem.
A concrete starter checklist you can copy
- Document symptom: when it happens, how often, on which platform.
- Create a reproducible harness: “performance room” with controlled triggers.
- Capture evidence: CPU + GPU + allocations (if available).
- Find top offenders: sort by total time and by spike window.
- Check allocations/GC: align GC spikes with frame-time spikes.
- Split domains: A/B disable visuals vs freeze gameplay.
- Look for scaling bugs: validate entity-count and scene-size sensitivity.
- Optimise for stability: compare worst-case frame times, not just averages.
- Commit in small steps: one bottleneck fix per commit, with notes.
Useful starting points
If you want engine-specific guidance for profiling and performance tooling, the docs below are good references:
- Unity Profiler documentation
- Windows Performance Counters overview
- Godot Engine (profiling and debugging documentation within the main docs)
- Unreal Engine performance and profiling documentation (within the developer docs)
The goal isn’t perfect profiling. It’s repeatable profiling that turns “I think it’s this” into “it’s this, here’s the evidence, and the spike went down after the fix”.