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.
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.
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.
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
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.
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.
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:
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");
}
}
}
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.
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.
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.
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.
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
}
}
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.
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;
}
}
}
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.
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.
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;
}
}
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.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.
[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.(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.
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.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.
(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.