Skip to main content

I Optimized the Wrong Thing (A Profiling Story)

· 2 min read
Yisi Liu
Game Developer

Six hours. That's how long I spent rewriting Sharpshooters' bullet system before realizing it wasn't the problem.

The Symptom

During wave 4, the frame rate would stutter every 3–4 seconds. Not a crash, not a freeze — a noticeable 20ms hike that made the game feel janky. I had 40 bullets on screen at peak. My first instinct: bullets are expensive.

What I Did Wrong

I immediately rewrote the bullet system to use object pooling. Bullets no longer instantiate and destroy — they activate and deactivate from a pool of 100. It took most of a weekend.

The stutter didn't go away.

What I Did Right (Eventually)

I opened Unity's Profiler. Five minutes after opening it, I found the actual culprit: the damage number UI.

Every hit spawned a TextMeshPro popup that displayed the damage value, animated upward, and then destroyed itself. At peak combat, that was 30–50 Instantiate calls per second — far more garbage than any bullet.

The fix took 45 minutes: a small pool of 20 text popups that get recycled instead of destroyed.

The Irony

The bullet pooling I spent the weekend on was still a good idea — it'll matter at higher bullet counts. But it was not the source of the problem I was trying to fix.

Rule I'm Taking Forward

Profile before you optimize. This is advice I'd heard a hundred times. Now I've paid the tuition.

My new process: reproduce the problem, open the Profiler, find the actual spike, then write code. Not before.

What the Profiler Showed

Frame time: 32ms (frame 1847)
GC.Collect 12.4ms ← here
TextMeshPro.Update 6.1ms
Physics.Simulate 4.8ms
Render 5.3ms

The GC.Collect call is the giveaway — that's the garbage collector running because something is allocating heavily. Bullets after pooling: 0 allocations. Text popups before pooling: ~800 bytes per popup × 50 popups/sec = ~40KB/sec of garbage.

Takeaway for Anyone New to Unity Performance

Three things generate GC pressure more than anything else in my experience:

  1. Instantiate/Destroy — use pooling for anything that spawns frequently
  2. String operations in Update()$"Score: {score}" creates a new string every frame
  3. LINQ in hot paths.Where(), .Select() allocate enumerators

Profile first. Know which of the three you're actually hitting before you rewrite anything.


Sharpshooters dev log. Follow along as I build a 3D shooter from scratch.