DEV Community

unity source code
unity source code

Posted on

5 Unity Architecture Mistakes That Quietly Kill Mobile Game Performance (And How to Fix Each One)

Every Unity developer has shipped a build that ran fine in the Editor and then stuttered, lagged, or crashed the moment it hit a real Android device. The instinct is usually to blame the device. The actual cause is almost always architecture decisions made early in development that seemed harmless at the time.

These five patterns show up constantly in first and second mobile projects. None of them are exotic. All of them are fixable in an afternoon once you know what to look for.


1. Instantiate/Destroy in Hot Paths

This is the single most common cause of mobile stutter, and it's almost invisible in the Editor because desktop CPUs handle garbage collection so much faster than mobile CPUs do.

// This pattern feels fine in the Editor
// It becomes a serious problem the moment it runs dozens of times per second
void SpawnCoin()
{
    Instantiate(coinPrefab, spawnPoint.position, Quaternion.identity);
}

void OnCoinCollected(GameObject coin)
{
    Destroy(coin);
}
Enter fullscreen mode Exit fullscreen mode

Every Instantiate() call allocates managed memory. Every Destroy() call eventually triggers garbage collection cleanup. In a game spawning coins, bullets, particles, or enemies continuously, this creates constant GC pressure — and on a mobile CPU, a GC pass can cause a visible, jarring frame drop.

The fix is object pooling. Pre-allocate a fixed pool of objects at scene start and reuse them instead of creating and destroying them on the fly.

public class ObjectPool : MonoBehaviour
{
    [SerializeField] private GameObject prefab;
    [SerializeField] private int poolSize = 50;
    private Queue<GameObject> pool = new Queue<GameObject>();

    void Awake()
    {
        for (int i = 0; i < poolSize; i++)
        {
            GameObject obj = Instantiate(prefab);
            obj.SetActive(false);
            pool.Enqueue(obj);
        }
    }

    public GameObject Get()
    {
        if (pool.Count == 0)
        {
            return Instantiate(prefab); // grow rather than stall
        }
        GameObject pooled = pool.Dequeue();
        pooled.SetActive(true);
        return pooled;
    }

    public void Return(GameObject obj)
    {
        obj.SetActive(false);
        pool.Enqueue(obj);
    }
}
Enter fullscreen mode Exit fullscreen mode

If you profile a build on a real device before and after switching to pooling, the GC spikes in the CPU Usage module of the Profiler will visibly disappear. This one change resolves more mobile stutter tickets than any other single optimization.


2. GetComponent() Calls Inside Update()

This one is subtle because it doesn't cause a dramatic frame drop — it causes a steady, low-grade performance tax that adds up across every active GameObject in your scene.

// Wrong — looks up the component every single frame
void Update()
{
    GetComponent<Rigidbody>().velocity = Vector3.zero;
}
Enter fullscreen mode Exit fullscreen mode

GetComponent() is not free. It's a lookup, and calling it every frame across dozens or hundreds of active objects is wasted CPU cycles that compound on weaker mobile hardware.

// Right — cache the reference once
private Rigidbody _rb;

void Awake()
{
    _rb = GetComponent<Rigidbody>();
}

void Update()
{
    _rb.velocity = Vector3.zero;
}
Enter fullscreen mode Exit fullscreen mode

Cache in Awake(), reference the cached variable everywhere else. This applies to any component lookup, not just RigidbodyTransform, Animator, Renderer, anything you're calling GetComponent on repeatedly belongs in a cached field.


3. Scattering Ad and Monetization Logic Across Multiple Scripts

This is an architecture mistake rather than a performance one, but it costs just as much time and money in a different way — usually after launch, when you're trying to tune ad frequency based on real retention data and discover the relevant code is spread across five different files.

// Scattered pattern — looks harmless at first
// GameManager.cs
void OnLevelComplete()
{
    AdMobController.ShowInterstitial(); // direct call buried here
}

// PlayerController.cs
void OnDeath()
{
    AdMobController.ShowRewarded(); // direct call buried here too
}
Enter fullscreen mode Exit fullscreen mode

The problem isn't that any individual call is wrong — it's that when you need to change your interstitial frequency cap after watching Day-1 retention drop post-launch, you have to hunt through every script that touches ads to make a coordinated change.

The fix is a centralized manager. One AdsManager singleton handles initialization, loading, and showing for every ad format. Every other script calls into it instead of touching ad SDKs directly.

public class AdsManager : MonoBehaviour
{
    public static AdsManager Instance { get; private set; }

    void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
        }
    }

    public void ShowInterstitial() { /* centralized logic here */ }
    public void ShowRewarded(System.Action onRewarded) { /* centralized logic here */ }
}
Enter fullscreen mode Exit fullscreen mode
// Now every script just calls the manager
AdsManager.Instance.ShowInterstitial();
Enter fullscreen mode Exit fullscreen mode

When frequency tuning is needed later — and it will be, the moment you see real player data — you change one file instead of searching your entire codebase. The full AdMob-specific implementation, including initialization order and per-format loading code, is linked at the end of this post.


4. Letting Draw Calls Run Unbatched

Desktop GPUs barely notice high draw call counts. Mobile GPUs — and more specifically, mobile CPUs preparing those draw calls — absolutely do. Every separate material instance on screen is a separate draw call, and unbatched draw calls are one of the most common reasons a scene that looks simple still runs poorly on budget Android devices.

The fastest way to see this for yourself: open Window > Analysis > Frame Debugger and step through your scene's rendering. You'll often find that visually similar objects — coins, UI icons, particle effects — are each generating their own draw call because they're using slightly different material instances instead of a shared one.

The fix in most cases is straightforward: combine sprites into a texture atlas, use a single shared material, and let Unity's static or dynamic batching combine the draw calls automatically. The Frame Debugger will show you exactly where batching is breaking — usually a material property override or an inconsistent sorting layer is the culprit.


5. Importing Textures at Full Resolution and Letting Unity Downscale at Runtime

This one is invisible in the Editor and brutal on budget devices. A 4K texture imported for a UI icon that's displayed at 200x200 pixels on screen isn't just wasted disk space — on a low-memory Android device, it can contribute to texture thrashing, where the OS is constantly swapping textures in and out of GPU memory and causing visible hitches.

Don't: import at 2048x2048 and let Unity downscale at runtime
Do: set Max Size in the texture import settings to match actual display size
Enter fullscreen mode Exit fullscreen mode

Set appropriate max texture sizes per-asset in the import inspector, and use mobile-appropriate compression formats — ASTC for modern devices, with ETC2 as a fallback for older Android hardware. This is a five-minute pass through your project's texture import settings that can meaningfully reduce memory pressure on the devices most likely to struggle.


Why This Matters Beyond Just Frame Rate

It's worth being direct about why this connects to more than smooth gameplay: a game that stutters or crashes in the first session gets uninstalled before a player ever sees your monetization, your progression system, or whatever makes the game actually good. Every dollar of potential ad revenue depends on a player staying engaged long enough to encounter those moments — and that's gated entirely by whether the game feels solid on the hardware your actual audience owns, which for most markets is a mid-range or budget Android device, not the flagship phone sitting on your desk.

None of the five patterns above are advanced techniques. They're baseline habits — pool instead of instantiate/destroy, cache instead of repeatedly calling GetComponent, centralize ad logic instead of scattering it, batch your draw calls, and respect texture memory limits. Building these habits early means you're not retrofitting a published game after Play Console reviews start mentioning lag.

If you're building on a template rather than from scratch, starting from a project where these patterns are already implemented correctly removes a meaningful amount of risk before you write your first line of custom code. You can browse a library of templates built around these architecture patterns at Unity Source Code, and if you're ready to take a clean, well-architected template and connect it to AdMob the right way, the full technical walkthrough — initialization sequencing, centralized AdsManager implementation, mediation setup, and the complete pre-launch checklist — is here: How to Integrate AdMob into a Unity Game Template and Maximize Ad Revenue in 2026.

Top comments (0)