15.2 Mobile & Cross-Platform Constraints

Phase 15 · Optimization & Mobile · Study time: 25–45 h

The realities of phones — thermal throttling, battery, many GPU tiers — and shipping one game across mobile, PC and console.

A game built for Unity or Unreal usually runs first on a developer's PC, where there is no shortage of anything: plenty of RAM, a graphics card with its own dedicated memory, a wall outlet for power, and fans to carry heat away. The moment that same game has to run on a phone, almost every one of those assumptions breaks. A phone has no fan, a battery instead of a wall outlet, and one chip doing the job of several PC components at once. This chapter is about the constraints that come from that difference — thermal throttling, battery drain, a hard memory ceiling, a different kind of GPU, and a much wider range of screens and input devices — and about the concrete engineering practices, mostly in C# for Unity, that keep a mobile live-service game playable across a three-year-old budget phone and a brand-new flagship at the same time.

1. Why a Phone Is Not a Small PC

It is tempting to think of a phone as just a smaller, weaker PC — same idea, fewer teraflops. That mental model causes real bugs, because a phone does not just have less of everything a PC has; it is built on a genuinely different set of trade-offs.

Almost every component in a gaming PC is a separate physical part: a CPU, a graphics card with its own dedicated video memory (VRAM), a power supply plugged into a wall outlet, and fans or heat pipes carrying heat out of the case. A phone squeezes the CPU, GPU, and memory controller onto a single chip called a SoC (system on a chip — a chip that manufactures an entire computer's core components together on one piece of silicon), running off a battery, sealed inside a case with no fan at all. Because the CPU and GPU share the same physical memory chips instead of the GPU having its own separate VRAM, that shared pool is often called unified memory.

typical gaming PC typical mid-range phone cooling fans + large heatsink none -- sealed case, no fan power source wall outlet, unlimited one battery, a few thousand mAh CPU + GPU two separate chips one SoC (system on a chip) memory 16-32 GB RAM, plus 4-6 GB RAM, shared between separate dedicated VRAM CPU and GPU (unified memory) over-budget memory OS slows down, may use OS kills the app outright, disk as overflow space no warning screen shape one flat rectangle notches, rounded corners, gesture bars, many sizes

Every one of those differences turns into a real engineering constraint, and this chapter covers each one in turn: heat that has nowhere to go (thermal throttling), a battery that drains faster the harder the chip works, a memory limit the operating system enforces by force-closing your game, a GPU built around a completely different rendering trick than a PC GPU, and a much wider variety of screens and input methods than a PC ever has to deal with. A mobile live-service RPG has to handle all of this at once, every single day, on millions of different physical devices it was never tested on directly.

2. Thermal Throttling: The Chip Slows Itself Down

Thermal throttling is when the chip's own firmware deliberately reduces CPU and GPU clock speed (how many operations per second the chip runs at) once an internal temperature sensor crosses a safety threshold. This is not a bug and not something you can turn off from your game's code — it is a protection built into the hardware so the chip does not damage itself or burn the player's hand.

A PC handles heat with moving air: fans push cool air across a heatsink, carrying heat out of the case. A phone has none of that. The only way heat leaves a phone is by slowly conducting through the case to the surrounding air, which is far slower than a fan. Run the chip hard for a few minutes and heat builds up faster than it can escape, so the firmware throttles the clock speed down until temperature stabilizes.

The dangerous part for a developer is that throttling only shows up after sustained load. Here is a simple script that logs the average frames per second (fps) over rolling 30-second windows, so you can watch what actually happens across a full session instead of a single moment:

using UnityEngine;

public class FpsSessionLogger : MonoBehaviour
{
    float windowTime = 0f;
    int windowFrames = 0;
    float windowFpsSum = 0f;

    void Update()
    {
        float fps = 1f / Time.unscaledDeltaTime;
        windowFpsSum += fps;
        windowFrames++;
        windowTime += Time.unscaledDeltaTime;

        if (windowTime >= 30f)
        {
            float avgFps = windowFpsSum / windowFrames;
            Debug.Log("avg fps this 30s window: " + avgFps.ToString("F1"));
            windowTime = 0f;
            windowFrames = 0;
            windowFpsSum = 0f;
        }
    }
}

A representative log from a real mid-range phone playing a graphically demanding scene for 20 minutes might look like this (exact numbers vary by device, but the shape is very common):

[0:30] avg fps: 59.8
[1:00] avg fps: 59.6
[1:30] avg fps: 58.1
[2:00] avg fps: 54.3
[2:30] avg fps: 47.9
[3:00] avg fps: 41.2
[3:30] avg fps: 33.6
[4:00] avg fps: 30.4
[4:30] avg fps: 30.1
[10:00] avg fps: 29.9
[20:00] avg fps: 29.8
fps 60 |** | ** 50 | *** | *** 40 | **** | *** 30 | *********************************** | 0 +------------------------------------------------------------- 0 1 2 3 4 5 6 8 10 15 20 (minutes) [ 30s benchmark ] --> "60 fps! ship it." a real play session keeps going past that point: the chip is now hot, clock speed has been throttled down by firmware, and fps settles around 30 for the rest of the session

The first 30 seconds look great — a benchmark run of that length would report something close to 60 fps and pass. But the chip has not had time to heat up yet at that point. By minute three or four, sustained load has raised the chip's temperature enough that firmware throttling kicks in, and frame rate settles at a new, lower, sustained value for the rest of the session. A live-service RPG session commonly runs 15-30 minutes at a time (a daily dungeon run, an event, a boss fight) — long enough for throttling to matter on almost every real device, every single day.

Common mistake Trusting a single "average fps" number from a short benchmark tool without looking at how it changes minute by minute. A 20-30 second benchmark almost always runs before the chip has fully heated up, so it measures the game's best case, not what a player actually experiences ten minutes into a real session.
Tip Always test with play sessions of at least 15-20 minutes on real hardware, not a simulator or a short clip, and watch the frame time graph over that whole window, not just the first few seconds.

3. Battery Drain and Why Players Quit "Hot" Games

Running the CPU and GPU at high clock speed does not just produce heat — heat is the wasted portion of the electrical power the chip is drawing. The harder the chip works, the more power it pulls from the battery and the more heat it produces, at the same time, for the same reason. Thermal throttling (Section 2) and battery drain are two symptoms of the exact same cause.

Phone batteries are usually rated in mAh (milliamp-hours, a unit of electric charge — roughly, how much current the battery can supply for how long) at a nominal voltage of about 3.85V. Multiplying the two gives the battery's total energy in Wh (watt-hours):

battery capacity   = 4000 mAh = 4.0 Ah
nominal voltage    = 3.85 V
total energy       = 4.0 Ah * 3.85 V = 15.4 Wh

demanding 3D game drawing ~4.0 W continuously:
  playtime = 15.4 Wh / 4.0 W = 3.85 hours

well-optimized game drawing ~2.0 W continuously:
  playtime = 15.4 Wh / 2.0 W = 7.7 hours

Same battery, same phone, roughly double the playtime just from how much power the game's rendering and simulation demand each second. That gap is not a minor detail for a live-service game the studio wants players to open every day.

Players notice two different things when a game draws too much power, and both hurt retention:

There is also a quieter third effect: a game that draws enough power to trigger thermal throttling (Section 2) makes itself feel worse at the exact same time it is draining the battery fastest, because frame rate drops right when the phone is hottest. A player's worst experience of the game and their least willingness to keep playing happen together, not separately.

Tip For a live-service game built around short daily sessions (dailies, a dungeon run, an event), a session that leaves the phone hot and the battery visibly drained works directly against the goal of getting the player to open the app again later the same day.

4. The Memory Ceiling: When the OS Kills Your App

A PC that runs low on RAM slows down — the operating system starts using disk space as overflow memory (called paging or swapping), which is much slower than RAM but keeps programs running. Phones generally do not give an app that safety net. Both major mobile operating systems watch how much memory every app is using, and when an app crosses a limit — either a hard per-app limit or the system running low on memory overall — the OS simply kills the app. No warning dialog, no exception a player can screenshot, no crash log they can send you unless you built your own reporting for it. The game just vanishes from the screen.

Android calls this the low memory killer / out-of-memory (OOM) killer; iOS calls its version jetsam. Different names, same idea: memory pressure crosses a line, and the foreground app is terminated to protect the rest of the system.

Neither company publishes an exact, fixed number for where that line sits — it depends on the device's total RAM, the OS version, the manufacturer's own software layered on top of Android, and how many other apps are already open in the background. What follows are rough, illustrative planning numbers, not guarantees, and you should always confirm real behavior with real devices and real kill logs before trusting a budget:

approximate memory budget before real risk of an OS kill (illustrative only -- verify against real devices) Low tier (~2-3 GB total RAM) [###### ] ~200 MB Mid tier (~4-6 GB total RAM) [################## ] ~550 MB High tier (~8-12 GB total RAM) [######################################] ~1500 MB

Notice the pattern: a low-end device's budget is not just "somewhat less" than a flagship's — it can be an order of magnitude smaller. A texture set, an audio bank, or a level that comfortably fits on a flagship can crash the exact same game outright on a three-year-old budget phone, with no error message beyond "the app closed."

You can watch your own game's memory use at runtime with Unity's profiler API, which is useful for catching a slow memory leak (memory that keeps growing and is never freed) before a player's device catches it for you:

using UnityEngine;
using UnityEngine.Profiling;

public class MemoryWatcher : MonoBehaviour
{
    public float warnThresholdMB = 500f;

    void Update()
    {
        long usedBytes = Profiler.GetTotalAllocatedMemoryLong();
        float usedMB = usedBytes / (1024f * 1024f);

        if (usedMB > warnThresholdMB)
        {
            Debug.LogWarning("Memory over budget: " + usedMB.ToString("F1") + " MB");
        }
    }
}
Common mistake Testing memory usage only with a freshly rebooted device running nothing else. A real player has a browser, a chat app, and a music player already sitting in the background, all sharing the same limited RAM. Your game's actual safe budget in the field is smaller than what you measure on a clean test device.

5. Tile-Based Deferred Rendering: How Mobile GPUs Actually Work

Most desktop and console GPUs render using what is called immediate mode: each draw call shades its pixels and writes them straight out to a large pool of dedicated video memory (VRAM) over a very wide, very fast memory bus. If two triangles overlap on screen, both get shaded and both get written to VRAM — the second one just overwrites the first. That repeated work on overlapping pixels is called overdraw, and a PC GPU can generally afford a fair amount of it because its memory bus has so much bandwidth to spare.

Almost all mobile GPUs work differently, using an approach called tile-based deferred rendering (TBDR): the screen is split into small tiles, and each tile is processed almost entirely using tiny, extremely fast on-chip memory before the finished result is written out to main memory once.

IMMEDIATE MODE (typical desktop/console GPU) draw call 1 --> shade pixels --> write straight to VRAM (off-chip) draw call 2 --> shade pixels --> write straight to VRAM (off-chip) draw call 3 --> shade pixels --> write straight to VRAM (off-chip) overlapping pixels get shaded and written more than once (overdraw), paid for with a very wide, very fast memory bus TILE-BASED DEFERRED RENDERING (typical mobile GPU) screen split into small tiles, e.g. 32x32 pixels +--------+--------+--------+--------+ | tile | tile | tile | tile | +--------+--------+--------+--------+ | tile | tile | tile | tile | +--------+--------+--------+--------+ for each tile, the GPU: 1. collects every triangle that touches this tile 2. resolves which surface is visible per pixel (hidden-surface removal) 3. shades ONLY the visible pixels, using small, fast on-chip memory 4. writes the finished tile out to main memory once overdraw of OPAQUE geometry inside a tile is almost free, because step 2 throws away hidden pixels before step 3 ever shades them

This is genuinely good news for opaque geometry (solid objects with no transparency) — a mobile GPU can often handle overlapping opaque objects more cheaply than you would expect, because it works out which pixel actually ends up visible before spending any shading work on it. But two very common rendering techniques break the trick that makes this fast:

This matters directly for a mobile action RPG built around flashy combat: a character's elemental skill might spawn a dozen overlapping particle effects, a screen-wide flash, and a bloom pass all at once. Every one of those is exactly the kind of work TBDR handles worst, on exactly the hardware with the least room to spare — which is why mobile builds of these games tend to visibly cap particle counts and simplify post-processing compared to a PC build of the same skill.

Tip When a mobile build's fps drops specifically during a big combat skill or a crowded fight, look first at overlapping transparent VFX and stacked full-screen effects before assuming the problem is raw triangle count or texture size.

6. Texture Compression Per Platform

A texture file like a PNG or JPEG on disk is compressed for file size, but once loaded, the GPU needs raw, uncompressed pixel data to render it — so the game normally decompresses the whole thing into memory first. A GPU texture compression format is different: it is a format the GPU's own hardware can read and decode on the fly, pixel by pixel, while rendering, so the texture can stay compressed in memory the entire time it is used. That difference is the single biggest lever you have over how much memory your textures actually cost.

ASTC (Adaptive Scalable Texture Compression) is the modern standard GPU compression format across both current Android GPUs and iOS/Metal devices. Its defining feature is a choice of block size — the pixel dimensions of the group of pixels compressed together — from 4x4 (highest quality, largest size) up to 12x12 (lowest quality, smallest size). Every ASTC block, no matter the block size, always takes exactly 128 bits (16 bytes) of storage, so a larger block size spreads those same 16 bytes over more pixels:

1024 x 1024 texture, one mip level

format          bytes per pixel     total size
RGBA32 (raw)    4.00                4.00 MB
ASTC 4x4        1.00                1.00 MB
ASTC 6x6        0.44                0.44 MB
ASTC 8x8        0.25                0.25 MB
ASTC 12x12      0.11                0.11 MB

Picking the wrong format for a platform is a very easy way to blow a memory budget without noticing: an uncompressed RGBA32 texture, or a legacy format left over from an old project, can cost four to eight times more memory than the same texture stored as ASTC at a reasonable block size — for a difference in visual quality most players will never spot on a phone screen held at arm's length.

In Unity, texture compression is set per platform through the texture's import settings, and it is worth setting this explicitly rather than trusting the default:

#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;

public class SetMobileTextureFormat
{
    [MenuItem("Tools/Set ASTC 6x6 For Selected Textures")]
    static void SetAstc()
    {
        foreach (Object obj in Selection.objects)
        {
            string path = AssetDatabase.GetAssetPath(obj);
            TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter;
            if (importer == null) continue;

            TextureImporterPlatformSettings settings = importer.GetPlatformTextureSettings("Android");
            settings.overridden = true;
            settings.format = TextureImporterFormat.ASTC_6x6;
            settings.compressionQuality = 50;
            importer.SetPlatformTextureSettings(settings);
            importer.SaveAndReimport();
        }
    }
}
#endif

A practical rule most mobile projects settle on: use a smaller, higher-quality block size like ASTC 4x4 or 6x6 for things players look at closely and often — character faces, UI icons, hero weapon skins — and a larger, cheaper block size like ASTC 8x8 for things seen from a distance or briefly — background scenery, environment props, distant terrain. The quality loss at a larger block size is far less noticeable on a busy background than on a close-up character face.

7. Resolution Scaling and Dynamic Resolution

The resolution a game renders at does not have to match the phone screen's native resolution. Rendering at a lower internal resolution and then scaling the finished image up to fill the screen reduces the number of pixels the GPU has to shade every frame (this cost is often called fill-rate, the number of pixels a GPU can shade per second) — which matters most exactly where Section 5 said mobile GPUs are weakest: overdraw, alpha blending, and full-screen effects, all of which cost more the more pixels there are.

The simplest version of this is a fixed render scale chosen per device tier (Section 9) — for example, rendering at 75% resolution on a low-end phone and 100% on a flagship. A more advanced version, dynamic resolution, adjusts the render scale continuously at runtime based on how the game is actually performing right now, rather than committing to one fixed number:

using UnityEngine;

public class DynamicResolutionController : MonoBehaviour
{
    public float targetFrameTimeMs = 16.6f; // 60 fps budget
    public float minScale = 0.6f;
    float currentScale = 1f;

    void Update()
    {
        float frameMs = Time.unscaledDeltaTime * 1000f;

        if (frameMs > targetFrameTimeMs * 1.15f && currentScale > minScale)
        {
            currentScale = Mathf.Max(minScale, currentScale - 0.05f);
            ScalableBufferManager.ResizeBuffers(currentScale, currentScale);
        }
        else if (frameMs < targetFrameTimeMs * 0.9f && currentScale < 1f)
        {
            currentScale = Mathf.Min(1f, currentScale + 0.02f);
            ScalableBufferManager.ResizeBuffers(currentScale, currentScale);
        }
    }
}

Trace a few frames of a busy fight scene, targeting 16.6ms (60 fps):

frame   frameMs   action                          scale after
100     15.9      within budget, no change         1.00
101     19.8      over 1.15x budget -> scale down   0.95
102     20.1      still over -> scale down          0.90
103     18.4      still over -> scale down          0.85
104     16.0      within budget, no change          0.85
105     13.2      under 0.9x budget -> scale up      0.87

The scale steps down quickly when frame time spikes (a heavy combat moment) and creeps back up slowly once things calm down, so the game spends most of its time as close to full resolution as it can sustain, instead of picking one fixed compromise and living with it during both the calm and the busy moments.

Tip Change resolution scale gradually, not in one big jump. A sudden resolution change is visually distracting; a slow ramp is much harder for a player to consciously notice, even though the game is constantly adjusting underneath them.

8. Choosing 30 fps vs 60 fps

Every frame has a time budget: at 30 fps, the game has 33.3 milliseconds to do everything needed for one frame; at 60 fps, it only has 16.6 milliseconds — half as long. Hitting 60 fps consistently, especially once a phone has been playing for ten minutes and thermal throttling (Section 2) has already reduced the chip's available power, is a much harder target than hitting 30.

30 fps frame budget = 1000 ms / 30  = 33.3 ms per frame
60 fps frame budget = 1000 ms / 60  = 16.6 ms per frame

Frame rate is not just a number on a settings screen — it changes how a game feels to play. An action-combat game where the player is timing dodges and combos against fast enemy attacks benefits a lot from 60 fps, because every extra frame is extra information about exactly when to react. A turn-based game, where the player picks an action and then watches a resolved outcome, is far more tolerant of 30 fps, because there is no fast, reflex-timed input to make feel sluggish.

Because device capability varies so much (Section 9) and player preference varies too — some players would rather have a cooler, longer-lasting phone than the smoothest possible frame rate — most live-service mobile games expose the choice directly in settings, rather than picking one fixed target for everyone:

using UnityEngine;

public class FrameRateOption : MonoBehaviour
{
    public void SetFrameRateOption(int fps)
    {
        QualitySettings.vSyncCount = 0; // targetFrameRate is ignored while vSync is on
        Application.targetFrameRate = fps; // pass 30 or 60
    }
}
Common mistake Setting Application.targetFrameRate without also setting QualitySettings.vSyncCount = 0. If vSync is on, Unity ignores targetFrameRate entirely and instead locks to whatever rate the display's vSync allows, which quietly defeats a frame rate option the player thinks they chose.

Offer a sane default per device tier (a low-end device probably should not default to a 60 fps option it cannot actually sustain), but let the player override it — some players on a mid-tier phone will happily accept a lower, steadier frame rate in exchange for a phone that stays cool through a long session.

9. Device Tiers: Detecting a Low-End Phone

Rather than shipping one fixed quality setting for every device, most mobile games sort devices into a small number of device tiers (buckets like Low, Mid, and High) using a quick heuristic (a fast, approximate rule rather than a perfect measurement) at first launch, apply a quality preset that matches the detected tier automatically, and then still let the player change individual settings afterward.

public enum DeviceTier { Low, Mid, High }

public static class DeviceTierDetector
{
    public static DeviceTier Detect()
    {
        int ramMB = SystemInfo.systemMemorySize;

        if (ramMB <= 3072) return DeviceTier.Low;
        if (ramMB <= 6144) return DeviceTier.Mid;
        return DeviceTier.High;
    }
}
using UnityEngine;

public class QualityManager : MonoBehaviour
{
    void Start()
    {
        DeviceTier tier = DeviceTierDetector.Detect();
        ApplyTier(tier);
    }

    void ApplyTier(DeviceTier tier)
    {
        switch (tier)
        {
            case DeviceTier.Low:
                QualitySettings.SetQualityLevel(0, true);
                Application.targetFrameRate = 30;
                QualitySettings.shadowDistance = 0f;
                ScalableBufferManager.ResizeBuffers(0.75f, 0.75f);
                break;

            case DeviceTier.Mid:
                QualitySettings.SetQualityLevel(1, true);
                Application.targetFrameRate = 30;
                QualitySettings.shadowDistance = 20f;
                break;

            case DeviceTier.High:
                QualitySettings.SetQualityLevel(2, true);
                Application.targetFrameRate = 60;
                QualitySettings.shadowDistance = 40f;
                break;
        }
    }
}
app starts | v read SystemInfo.systemMemorySize (RAM in MB) | ------------------------------------------------ | | | RAM <= 3072 3072 < RAM <= 6144 RAM > 6144 | | | v v v Tier: LOW Tier: MID Tier: HIGH 30 fps cap 30 fps cap 60 fps offered shadows off low shadows full shadows render scale 0.75 render scale 0.9 render scale 1.0 ASTC 8x8 textures ASTC 6x6 textures ASTC 4x4 textures

A pure RAM check is a good starting point, but it is not perfect — every generation has a handful of specific phone models where total RAM looks generous on paper but the actual GPU is weak, or the opposite. Serious mobile projects keep a small manual override table for known problem devices (matched by SystemInfo.deviceModel), checked before falling back to the RAM heuristic, rather than trusting one number to always tell the truth.

Tip Whatever the detector picks, treat it only as the starting setting. Always expose the individual options (frame rate, shadow quality, render scale) in a settings menu, because a heuristic guess is never going to be right for every single device it will ever run on.

10. Input Differences: Touch, Gamepad, and Mouse

A cross-platform live-service game commonly has to support touch on phones, mouse and keyboard on PC, and a gamepad on both PC and console — three genuinely different ways of telling the game what the player wants, not just three different button layouts for the same idea:

Because the same game may run with any of these depending on the platform (and, on some devices, a player might plug in a gamepad while still holding a phone), UI and control code should detect what is actually connected right now rather than assuming one input type is always present:

using UnityEngine;
using UnityEngine.InputSystem;

public class InputModeSwitcher : MonoBehaviour
{
    public GameObject virtualJoystick;

    void Update()
    {
        bool gamepadConnected = Gamepad.current != null;
        virtualJoystick.SetActive(!gamepadConnected && Application.isMobilePlatform);
    }
}

This check runs every frame specifically because the connected input can change mid-session — a player might plug in a gamepad partway through play — and the UI should react immediately rather than requiring a restart.

11. Safe Areas and Notches

A modern phone screen is not a clean rectangle from a game's point of view. A camera cutout (a notch or hole-punch where the front camera sits) can overlap the top of the screen, corners are often physically rounded, and a system gesture bar can sit along one edge — all of them areas where the operating system may draw its own UI over yours, or where your UI would simply be hidden or clipped if you place it there.

Unity exposes Screen.safeArea: a rectangle, reported by the OS, describing the region of the screen guaranteed not to be obstructed by any of that. Anchoring important UI — health bars, currency counters, a "skip" button — inside this rectangle instead of the raw screen edges keeps it visible on every device, notch or no notch:

using UnityEngine;

public class SafeAreaFitter : MonoBehaviour
{
    RectTransform panel;

    void Awake()
    {
        panel = GetComponent<RectTransform>();
        Apply();
    }

    void Apply()
    {
        Rect safe = Screen.safeArea;

        Vector2 anchorMin = safe.position;
        Vector2 anchorMax = safe.position + safe.size;

        anchorMin.x /= Screen.width;
        anchorMin.y /= Screen.height;
        anchorMax.x /= Screen.width;
        anchorMax.y /= Screen.height;

        panel.anchorMin = anchorMin;
        panel.anchorMax = anchorMax;
    }
}
physical screen +---------------------------------------+ | (camera notch area) | system camera cutout -- |-----------------------------------------| nothing important goes here | | | Screen.safeArea | | +---------------------------------+ | | | [ HP bar ] | | UI anchored inside this | | | | rect is guaranteed visible | | [ Skip ] | | on every device, notch or not | +---------------------------------+ | | | +---------------------------------------+ rounded corners / gesture bar area can also clip UI placed exactly at the raw screen edge
Common mistake Anchoring UI to the raw screen corners instead of Screen.safeArea. It looks correct on the developer's own test device and then hides a currency counter behind a camera cutout, or clips a "skip" button under a rounded corner, on a device the developer never personally tested.

12. Testing on the Cheapest Phone You Can Find

A developer's own phone is almost always well above what most players actually own — studios buy their engineers capable hardware, and a capable phone has enough spare thermal, memory, and GPU headroom to hide exactly the problems this chapter covers. Testing only on a flagship development device does not just miss some bugs; it structurally cannot find thermal throttling, memory kills, or low-end GPU rendering issues, because the flagship has enough margin that those problems never actually trigger during a normal test session.

same scene, same build, two devices, 12-minute play session

                    flagship device        budget device
start fps           60                      45
fps at 12 min       58                      22
skin temperature    38C                     44C
memory at 12 min    620 MB (fine)           340 MB
result              smooth session          app killed by OS at 11:40

Nothing in that flagship run would have told you the budget device was going to be killed by the operating system before the session even finished. The bug only exists on hardware with less headroom to absorb it.

The practical fix is to keep a small physical device lab, not just rely on a simulator or your own phone: at minimum one deliberately old or cheap Android device (something two to three years old, low RAM, a low-end GPU) alongside a mid-range device, and run full 15-20 minute play sessions on them regularly — not just an install-and-launch smoke test, since throttling (Section 2) and memory growth (Section 4) both need sustained time to show up at all.

Tip If you can only afford one extra test device beyond your own, make it the cheapest current phone you can find, not a second flagship. A second flagship mostly confirms what your first flagship already told you; a budget device tells you something new.

13. Glossary

14. Exercises

Exercise 1 — Read a Throttling Log A test session logged average fps every 30 seconds for the first 6 minutes of play:
[0:30] 58.9
[1:00] 58.5
[1:30] 57.8
[2:00] 55.0
[2:30] 49.6
[3:00] 42.1
[3:30] 35.4
[4:00] 30.8
[4:30] 30.2
[5:00] 30.0
[5:30] 29.9
[6:00] 29.8
(a) At roughly what point does frame rate first drop below 90% of the first window's value, and does it stay down afterward? (b) Compute the average fps across all 12 windows shown. (c) In one or two sentences, explain why a 20-second benchmark run at the very start of this session would have reported a misleading number.
Show answer

(a) 90% of the first window's value (58.9) is about 53.0. The 2:00 window (55.0) is still above that, but the 2:30 window (49.6) drops below it and every window after that stays below it too, so throttling becomes clearly visible starting around the 2:30-3:00 mark.

sum = 58.9+58.5+57.8+55.0+49.6+42.1+35.4+30.8+30.2+30.0+29.9+29.8
    = 508.0
average = 508.0 / 12 = 42.3 fps

(b) The average across the full 6 minutes is about 42.3 fps.

(c) A 20-second benchmark run right at the start would land inside the 0:30 window, reporting something close to 58.9 fps — a number more than 16 fps higher than the session's true 6-minute average, and roughly double the 29-30 fps the device actually settles at once it has been running long enough to heat up.

Exercise 2 — Add a Manual Override to the Detector The DeviceTierDetector from Section 9 sorts devices purely by RAM. Suppose a specific model, "BudgetPhone X9", reports 4096 MB of RAM (which the RAM check alone would classify as Mid tier) but is known to have a very weak GPU that cannot actually sustain Mid-tier settings. Modify DeviceTierDetector.Detect() so this specific model is always forced to DeviceTier.Low, checked before the RAM heuristic runs, while every other device still falls back to the normal RAM-based logic.
Show answer
public static class DeviceTierDetector
{
    static readonly string[] ForcedLowTierModels = { "BudgetPhone X9" };

    public static DeviceTier Detect()
    {
        foreach (string model in ForcedLowTierModels)
        {
            if (SystemInfo.deviceModel == model)
                return DeviceTier.Low; // known weak GPU -- override before the RAM check
        }

        int ramMB = SystemInfo.systemMemorySize;
        if (ramMB <= 3072) return DeviceTier.Low;
        if (ramMB <= 6144) return DeviceTier.Mid;
        return DeviceTier.High;
    }
}

The override list is checked first and returns immediately on a match, so "BudgetPhone X9" never reaches the RAM comparison at all, regardless of what SystemInfo.systemMemorySize reports for it. Every other device model falls through the loop untouched and is classified exactly as before, by RAM alone.

Exercise 3 — Compute a Texture Memory Budget A character model uses 8 textures, each 2048x2048, currently stored uncompressed as RGBA32. The rest of the scene (meshes, audio, UI, and everything else) already uses about 300 MB. The Mid device tier from Section 4 has an illustrative budget of roughly 400 MB before real risk of an OS kill. (a) Compute the total memory used by the 8 RGBA32 textures. (b) Convert them to ASTC 6x6 and compute the new total. (c) Does the scene fit the 400 MB budget before and after the conversion?
Show answer
(a) RGBA32, 2048x2048:
    pixels    = 2048 * 2048 = 4,194,304
    bytes     = 4,194,304 * 4 bytes/pixel = 16,777,216 bytes = 16.00 MB
    8 textures = 8 * 16.00 MB = 128.00 MB

(b) ASTC 6x6:
    bytes per pixel = 16 bytes / 36 pixels = 0.444 bytes/pixel
    bytes per texture = 4,194,304 * 0.444 = 1,864,135 bytes = 1.78 MB
    8 textures = 8 * 1.78 MB = 14.22 MB

(c) before: 300 MB + 128.00 MB = 428.00 MB  -> over the 400 MB budget
    after:  300 MB + 14.22 MB  = 314.22 MB  -> comfortably under budget

Stored as RGBA32, this one character alone pushes the scene past the Mid-tier memory budget by about 28 MB, putting it at real risk of being killed by the OS on a mid-range device. Converting the same 8 textures to ASTC 6x6 cuts their memory cost by roughly 9x (128.00 MB down to 14.22 MB) without changing a single pixel on disk in a way most players would notice, and brings the whole scene comfortably under budget with about 85 MB of headroom to spare.

← Back to all chapters