DMG Forge
ArticlesUnity TipsGamesAssets WIPCoursesAbout
ArticlesUnity TipsGamesAssets WIPCoursesAboutFree resources
LV 10 XP
Free resources
Now building:ColorDrop Frenzy

DMG Forge

Unity tips, articles, assets, and devlogs for creators who want to build and finish.

AchievementsSavedSkill treePrivacy
Resources/Guides

Unity guide

Updated Oct 11, 2026 · 23 min read

Why Your Unity Mobile Game Is Stuck at 30 FPS (and How to Fix It)

Your Unity Android game runs at exactly 30 FPS? Follow a step-by-step diagnosis: the mobile default, on-device measurement, frame pacing, power and heat, and the missing game flag that caps sideloaded builds.

#Unity#Android#Performance#Debugging

You set Application.targetFrameRate = 60, built to your phone, and the game feels smooth but heavy. You check the numbers and it’s running at exactly 30 FPS. Not 28, not 41 — a flat 30.0, report after report. Most advice online tells you to set the target frame rate, which you already did.

This guide is a diagnostic path for that symptom in Unity Android games, ordered from the cheapest, most likely check to the least obvious one. The core idea: a frame rate that’s an exact fraction of your target, with zero variance, isn’t a performance problem. Something is capping you. The guide grew out of a real case where every Unity setting checked out and the cap came from the phone itself, because a sideloaded build wasn’t recognized as a game.

How to use this guide

Work through the sections in order; each one finds the cause or rules it out in a single build. Unity behavior comes from Unity’s documentation for 2022.3 LTS and Unity 6, Android behavior from Android Developers and the Android Open Source Project (linked in Sources). Phone makers’ game apps are mostly undocumented, so where this guide describes them from observation, it says so. The 30 FPS default applies to iOS too, but the OS-level causes here are Android-specific. If a menu path doesn’t match your editor, your editor wins.

Why exactly 30

A phone screen doesn’t show a frame the moment it’s ready. It refreshes on a fixed beat — every 16.7 ms on a 60 Hz panel, every 8.3 ms at 120 Hz — and a finished frame waits for the next beat. So the rates you can actually see are the refresh rate divided by a whole number: 60, 30, or 20 FPS on a 60 Hz screen.

That’s why a struggling game and a capped game can both land on 30. Miss the 16.7 ms deadline and the frame waits for the following refresh, so it stays on screen for 33.3 ms. Get capped and the frame is ready early but held back anyway. The screen output is identical; what differs is how busy the device was.

A · On budget — 60 FPSEach frame finishes inside its 16.7 ms window, so a new frame reaches the screen at every refresh.A · On budget — 60 FPS. Each frame finishes inside its 16.7 ms window, so a new frame reaches the screen at every refresh.
B · Overloaded — 30 FPSEach frame runs past the next refresh, misses it, and waits for the one after: 33.3 ms per frame.B · Overloaded — 30 FPS. Each frame runs past the next refresh, misses it, and waits for the one after: 33.3 ms per frame.
C · Capped — 30 FPSEach frame is done in a few milliseconds, then held back anyway. Same screen output as B, mostly idle device.C · Capped — 30 FPS. Each frame is done in a few milliseconds, then held back anyway. Same screen output as B, mostly idle device.
Work (CPU + GPU) for one frameWaiting — ready, but held backFrame on screen (a color change = a new frame)Missed refreshDashed line = one refresh (16.7 ms at 60 Hz)
Three ways a game meets a 60 Hz screen. Rows B and C both show 30 FPS — only the work bars tell them apart.

Two kinds of 30

  • Overloaded 30. Frame times vary. Some frames make the deadline and some don’t, so the average wobbles (41.7, 52.3, 30.6) and the CPU or GPU time per frame sits near or above the budget. This is a performance problem: profile and optimize.
  • Capped 30. Every frame is on screen for exactly 33.3 ms. The average reads 30.0 in every report, every frame misses the 60 FPS budget, and the CPU and GPU are idle for most of each frame. Optimizing your code won’t move it, because your code isn’t the bottleneck.

Pro tip

The tell is zero variance. Real overload is noisy. A flat, exact fraction of your target — 30.0, 45.0, 40.0 — means something between your request and the screen is choosing the rate.

The spec sheet

These are the hard rules that decide which frame rate you get on Android. Unity rows hold for 2022.3 LTS and Unity 6 unless noted.

Frame rate facts for Unity on Android
targetFrameRate default-1. On Android and iOS that renders at a fixed 30 FPS, independent of the display’s refresh rate
vSyncCount on mobileIgnored on Android and iOS — the target frame rate decides
Upper limitThe display’s maximum refresh rate
Uneven targetsRounded down to the nearest rate that divides the current refresh rate (60 Hz, target 25 → 20 FPS)
Rates on a 60 Hz panel60, 30, 20 FPS
Rates on a 90 Hz panel90, 45, 30 FPS
Rates on a 120 Hz panel120, 60, 40, 30 FPS
Frame budget16.7 ms at 60 FPS, 33.3 ms at 30 FPS, 11.1 ms at 90 FPS, 8.3 ms at 120 FPS
Refresh rate in a scriptrefreshRateRatio of the current resolution (Unity 2022.2 and newer). The older refreshRate field is obsolete.
Game flagappCategory set to “game” on the manifest’s <application> element, Android 8.0 (API level 26) and newer. The older isGame attribute is deprecated since API level 26.
Unity setting for the flagUnity 6: Other Settings → Configuration → App Category (Game by default in new projects). Unity 2022.3: the “Android Game” option is only shown when Android TV Compatibility is on
Game Mode and interventionsSelect Android 12 devices and all devices on Android 13 and newer. FPS throttling: Android 13 and newer
Frame timing dataFrameTimingManager is always on in development builds; release builds need Frame Timing Stats enabled

The diagnostic path

Each step costs one build or less, and the order puts cheap, common causes first. Don’t skip measuring: every question after the first one depends on the numbers the probe in Measure on device produces.

  1. 1Does the log show targetFps=-1, or a target you didn’t set?Yes → The target never appliedSee Set the target
  2. 2Does avgFps wobble, with mainMs or gpuMs close to the frame budget?Yes → Overloaded — profile and optimizeSee Read the signature
  3. 3Does avgFps start at target and sag over minutes while thermal climbs?Yes → Thermal throttlingSee Power and heat
  4. 4Does turning Optimized Frame Pacing off change the numbers?Yes → A pacing interaction on this deviceSee Rule out frame pacing
  5. 5Is battery saver on, the charger in, or Game Mode set to battery?Yes → A power policySee Power and heat
  6. 6Is gameMode 0, or is the app missing from the phone’s game hub?Yes → Not recognized as a gameSee Flag it as a game
  7. ?All “no”? Test a second phone from a different maker and look through the maker’s battery and game settings.
Ask the questions top to bottom. The first “yes” is usually your cause.

Set the target

Start here because it’s free. Until something sets Application.targetFrameRate, it stays at -1, and on Android and iOS that means a fixed 30 FPS. Unity also ignores QualitySettings.vSyncCount on mobile, so a VSync setting in Quality settings won’t raise it.

Set the target before anything renders. A static method marked with the RuntimeInitializeOnLoadMethod attribute and BeforeSceneLoad runs before any Awake in the first scene, so it doesn’t depend on which object happens to load first:

Assets/Scripts/FrameRateBootstrap.cs
using UnityEngine;

static class FrameRateBootstrap
{
    // Runs before the first scene loads, so no frame renders at the 30 FPS default.
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    static void Apply()
    {
        Application.targetFrameRate = 60;
    }
}

What works

  • Set it once, early, in one place. A coroutine in Start that waits a frame before setting the target leaves the first frames at 30 and hides the call where it’s easy to break. Moving it to Awake or a bootstrap is good hygiene. In the case behind this guide it was the right fix that changed nothing, because the target was already being applied.
  • Pick a rate the panel can show. Unity rounds an uneven target down to a divisor of the current refresh rate. On a 90 Hz screen, a target of 60 becomes 45 unless the display switches to 60 Hz. Choose 90, 45, or 30 on purpose there.
  • Don’t cache the refresh rate. Unity’s documentation warns that a phone can change its refresh rate while the game runs — for example, dropping to 60 Hz while charging. Read refreshRateRatio from Screen.currentResolution when you need it.
  • Leave vSyncCount alone on mobile. Android ignores it. It only matters for desktop and web builds that share the same Quality settings.
  • Watch for later overrides. A settings menu, a “battery saver” option in your own game, or a third-party SDK can change the target after startup. The probe in the next section logs the value that’s actually in effect.

Do

  • Set the target before the first scene loads
  • Use 60 (or 30) on 60 Hz and 120 Hz panels
  • Choose 90, 45, or 30 deliberately on 90 Hz panels
  • Log the value that’s actually in effect

Don’t

  • Relying on the default (30 FPS on mobile)
  • Setting it in a coroutine that waits a frame
  • Expecting QualitySettings.vSyncCount to change a mobile frame rate
  • Hard-coding a refresh rate read once at startup

Watch out

If the target is right and the game still runs at a flat 30, stop changing settings. Every blind fix costs a build and teaches you nothing. Measure first.

Measure on device

This is the step that breaks the deadlock. Editor numbers say nothing about a phone, and an FPS counter only shows the result, not the reason. You need the requested target, the display’s refresh rate, the achieved rate, how many frames missed their budget, and how busy the device was — logged from the build, on the phone.

Add this component to any object in your first scene and make a Development Build. It writes one line to logcat every 5 s, prefixed with “FrameProbe” so you can filter for it:

Assets/Scripts/FrameProbe.cs
// On-device frame-rate diagnostics. Add to any GameObject in your first scene,
// make a Development Build, and read the output with adb logcat.
using UnityEngine;

public class FrameProbe : MonoBehaviour
{
    const string Tag = "FrameProbe";

    [SerializeField] float reportEverySeconds = 5f;
    [Tooltip("A frame counts as late when it takes longer than this many target intervals.")]
    [SerializeField] float lateFactor = 1.5f;
    [SerializeField] bool showOnScreen = false;

    readonly FrameTiming[] timing = new FrameTiming[1];
    int frames, lateFrames, timedFrames;
    float elapsed;
    double mainThreadMs, gpuMs;
    string lastReport = "";
    GUIStyle style;

    void Awake()
    {
        if (!Debug.isDebugBuild) { enabled = false; return; }
        DontDestroyOnLoad(gameObject);
    }

    void Update()
    {
        float dt = Time.unscaledDeltaTime;
        int target = Application.targetFrameRate > 0 ? Application.targetFrameRate : 30; // -1 = mobile default
        frames++;
        elapsed += dt;
        if (dt > lateFactor / target) lateFrames++;

        // Busy time per frame. Always available in Development Builds;
        // release builds need Player Settings > Frame Timing Stats.
        FrameTimingManager.CaptureFrameTimings();
        if (FrameTimingManager.GetLatestTimings(1, timing) > 0)
        {
            mainThreadMs += timing[0].cpuMainThreadFrameTime;
            gpuMs += timing[0].gpuFrameTime;
            timedFrames++;
        }

        if (elapsed >= reportEverySeconds) Report();
    }

    void Report()
    {
        double refreshHz = Screen.currentResolution.refreshRateRatio.value;
        string busy = timedFrames > 0
            ? $"mainMs={mainThreadMs / timedFrames:F1} gpuMs={gpuMs / timedFrames:F1}"
            : "mainMs=n/a gpuMs=n/a";
        lastReport = $"{Tag}: targetFps={Application.targetFrameRate} refreshHz={refreshHz:F1} " +
                     $"vSyncCount={QualitySettings.vSyncCount} avgFps={frames / elapsed:F1} " +
                     $"late={lateFrames}/{frames} {busy} {AndroidState()}";
        Debug.Log(lastReport);

        frames = lateFrames = timedFrames = 0;
        elapsed = 0f;
        mainThreadMs = gpuMs = 0;
    }

    void OnGUI()
    {
        if (!showOnScreen) return;
        style ??= new GUIStyle(GUI.skin.label) { fontSize = Mathf.Max(14, Screen.height / 60), wordWrap = true };
        GUI.Label(new Rect(16, 16, Screen.width - 32, Screen.height / 4f), lastReport, style);
    }

    // Battery saver, thermal status, and whether Android sees the app as a game.
    static string AndroidState()
    {
#if UNITY_ANDROID && !UNITY_EDITOR
        try
        {
            using var version = new AndroidJavaClass("android.os.Build$VERSION");
            int sdk = version.GetStatic<int>("SDK_INT");
            using var player = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
            using var activity = player.GetStatic<AndroidJavaObject>("currentActivity");
            using var power = activity.Call<AndroidJavaObject>("getSystemService", "power");
            bool powerSave = power.Call<bool>("isPowerSaveMode");
            int thermal = sdk >= 29 ? power.Call<int>("getCurrentThermalStatus") : -1;
            int gameMode = -1;
            if (sdk >= 31)
            {
                using var game = activity.Call<AndroidJavaObject>("getSystemService", "game");
                gameMode = game.Call<int>("getGameMode");
            }
            return $"powerSave={powerSave} thermal={thermal} gameMode={gameMode}";
        }
        catch (System.Exception e)
        {
            return $"androidState={e.GetType().Name}";
        }
#else
        return "";
#endif
    }
}

What it logs

  • targetFps. Application.targetFrameRate at report time. -1 means nothing set it.
  • refreshHz. The panel’s current refresh rate, read from refreshRateRatio (Unity 2022.2 and newer).
  • vSyncCount. Logged for completeness. Android ignores it, so any value is fine.
  • avgFps. Frames divided by seconds over the report window.
  • late. Frames that took longer than 1.5 × the target interval, out of all frames. At a 60 FPS target, every 33.3 ms frame counts.
  • mainMs and gpuMs. Average main-thread work and GPU time per frame, from FrameTimingManager. Unity’s main-thread figure excludes time spent waiting for VSync or the target frame rate, so it’s the real cost of a frame. gpuMs reads 0.0 where the GPU timer isn’t available.
  • powerSave, thermal, gameMode. Android’s battery saver state, the thermal status (0 none, 1 light, 2 moderate, 3 severe and up; Android 10 and newer), and the Game Mode Android gives this app (Android 12 and newer). -1 means the Android version doesn’t have that API. gameMode 0 means Android doesn’t consider your app a game.

Read it with adb

Connect the phone with USB debugging on, clear old output, and stream only the probe’s lines:

bash (macOS, Linux, Git Bash)
# Clear old output, then stream only the probe's lines
adb logcat -c
adb logcat -s Unity | grep "FrameProbe"
PowerShell (Windows)
# Clear old output, then stream only the probe's lines
adb logcat -c
adb logcat -s Unity | Select-String "FrameProbe"

Unity’s Android Logcat package shows the same lines in an Editor window if you prefer it to a terminal. Here’s what the capped case looks like for a fictional game, “Lantern Drift”, on a 120 Hz phone:

logcat — capped (timestamps trimmed)
FrameProbe: targetFps=60 refreshHz=120.0 vSyncCount=1 avgFps=30.0 late=150/150 mainMs=4.1 gpuMs=3.6 powerSave=False thermal=0 gameMode=0
FrameProbe: targetFps=60 refreshHz=120.0 vSyncCount=1 avgFps=30.0 late=150/150 mainMs=4.3 gpuMs=3.7 powerSave=False thermal=0 gameMode=0
FrameProbe: targetFps=60 refreshHz=120.0 vSyncCount=1 avgFps=30.0 late=150/150 mainMs=4.0 gpuMs=3.5 powerSave=False thermal=0 gameMode=0

Pro tip

Make the readout toggleable. A hidden gesture — for example, five taps on the top of the screen within 2 s — that flips showOnScreen lets testers read the numbers without a cable or a rebuild.

Read the signature

Put three reports side by side and match them to one of these patterns. The numbers are evidence; the interpretations are rules of thumb that point you to the next check, not guarantees.

  • targetFps is -1 or not what you set. The target never applied, or something reset it. Fix the call and stop here.
  • avgFps wobbles, some frames are late, mainMs or gpuMs is near the budget. Overloaded. Profile a development build with the Unity Profiler. No setting in this guide will help.
  • avgFps starts at target and sags over minutes while thermal climbs to 2 or more. Thermal throttling. See Power and heat.
  • avgFps is 45.0 with refreshHz 90.0 and targetFps 60. Divisor rounding: 60 doesn’t divide 90. Change the target.
  • avgFps is an exact fraction, every frame is late, busy time is far under budget. A cap. Something outside your game loop is choosing the rate. Keep going.

In the case behind this guide, the target read 60 on a 120 Hz panel and avgFps read 30.0 with every frame late, window after window: exactly half the target, with no noise at all. That pattern points away from the game’s own work and toward something capping it.

Rule out frame pacing

Optimized Frame Pacing is Unity’s integration of the Android Frame Pacing library, also called Swappy. It times when each frame is presented so frames don’t arrive early or pile up — the cause of stutter when a game’s rate doesn’t match the display. Because it decides presentation timing, it’s a plausible suspect when the rate lands on an exact fraction, and it’s cheap to test.

Unity settingPlayer Settings → Android → Resolution and Presentation → Optimized Frame Pacing (Unity 2019.2 and newer)
What it doesPresents frames at even intervals using presentation timestamps and sync fences, on OpenGL ES and Vulkan
Swap intervalHow long each frame stays on screen, for example 33.3 ms at 30 FPS
Multi-rate displaysThe library picks the supported refresh rate closest to the target frame rate

How to test it

  1. Turn Optimized Frame Pacing off, rebuild, and run the same scene.
  2. Compare three probe reports with the ones from before.
  3. If the numbers are identical, pacing isn’t your cap. Turn it back on: it exists to keep frames evenly spaced, and you want that once the real cause is fixed.
  4. If the numbers change, you’ve found an interaction between pacing and this device. Confirm it on a second phone and on your Unity version’s latest patch before you decide to ship with it off.

In the case behind this guide, the build with pacing off produced the same 30.0 and the same “every frame late”. Pacing went back on.

Pro tip

Two different app-side fixes that produce identical numbers are evidence in themselves. Whatever decides your frame rate isn’t listening to your app — look at the operating system next.

Power and heat

Android and phone makers can lower an app’s frame rate to save battery or cool the device. Some of that is documented and some isn’t, but each source has a check that takes minutes.

Battery saver and charging

Unity’s documentation notes that a phone can change its refresh rate at runtime, for example capping the display at 60 Hz while the battery charges. In practice, battery saver modes lower refresh rates on some phones too. The probe logs powerSave and refreshHz; turn battery saver off, unplug the charger, and compare.

Android Game Mode

On Android 13 and newer (and some Android 12 devices), games get per-app Game Modes — standard, performance, and battery — that players switch in Game Dashboard on Pixel phones or in the maker’s game app. Phone makers can attach interventions to those modes, including FPS throttling, which limits a game to a divisor of the refresh rate: 60 or 30 on a 60 Hz screen; 120, 60, 40, or 30 on a 120 Hz one. When your own pacing and a throttle both apply, the lower rate generally wins.

Game Mode only applies to apps Android considers games, so it matters most after you flag your build. To take it out of the equation while testing, force the standard mode:

bash or PowerShell
# Force a Game Mode for your package while testing (standard | performance | battery)
adb shell cmd game mode standard com.example.lanterndrift

If you don’t want maker-defined throttling at all, Android lets a game opt out of individual interventions in its Game Mode config file — see Google’s Game Mode interventions page.

Thermal throttling

Heat looks different from a cap. Throttling builds up over minutes of sustained load: the average starts at target and sags, late frames come and go, and the thermal status climbs. A cap is there from the first report on a cold phone. If heat is your problem, reduce the sustained load. Unity’s Adaptive Performance package reads Android’s thermal headroom and can scale quality for you, and Sustained Performance Mode (Player Settings → Android → Other Settings) trades peak speed for a steady level.

Watch out

Game Mode’s battery mode isn’t Android’s system Battery Saver. Android documents them as separate switches, so check both when a game runs slower than expected.

Game recognition

If the target, pacing, power, and heat all check out and the rate is still a flat fraction, ask whether the phone thinks your app is a game. Android and phone makers treat games differently, and a build you install yourself may not qualify.

How a phone decides

  • The manifest category. An app declares itself a game with android:appCategory="game" on its <application> element. Android’s GameManager reports Game Mode 0 (unsupported) for every app it doesn’t consider a game — that’s the gameMode value the probe logs.
  • The installer’s hint. When the manifest declares no category, the store that installed the app can supply one through setApplicationCategoryHint on PackageManager. A build installed with adb install or from a downloaded APK has no store behind it, so all it has is its manifest. Google doesn’t document what Play sends, but its Game Mode guide notes that Game Dashboard’s optimization controls may only appear after you install through Play.
  • The maker’s own list. Samsung says its Game Launcher automatically shows games downloaded from Play Store and Galaxy Store, and that games from unauthorized stores may not appear. Xiaomi’s Game Turbo works on a list of added games, and OnePlus and OPPO phones have their own game apps. How each one decides — and what it does to apps that aren’t on the list — isn’t publicly documented.

What we observed

The case behind this guide: a Unity 2022.3 game on a 120 Hz OnePlus phone, sideloaded during development. Target 60, every Unity setting correct, a flat 30.0 — and the app was missing from the phone’s game assistant list entirely. OnePlus doesn’t document a frame cap for apps it doesn’t recognize as games, so treat this as an observation, not a rule. Flagging the build as a game moved it to 60.0 with zero late frames; nothing else did.

Quick tests

  1. Check gameMode. On Android 12 and newer, a 0 in the probe output means Android doesn’t see a game.
  2. Check the phone’s game hub. Open Gaming Hub or Game Launcher (Samsung), Game Turbo (Xiaomi), or Games, Game Space, or Game Assistant (OnePlus and OPPO; names vary by OS version) and look for your app. If it’s missing, use the hub’s add option and measure again. A jump to your target is strong evidence.
  3. Check the built APK. Look for the flag in the manifest that actually shipped, not the one in your project. appCategory shows as 0, which is Android’s constant for “game”.
bash
# aapt2 ships in the Android SDK build-tools folder
aapt2 dump xmltree --file AndroidManifest.xml LanternDrift.apk | grep -E "appCategory|isGame"
PowerShell
# aapt2 ships in the Android SDK build-tools folder
aapt2 dump xmltree --file AndroidManifest.xml LanternDrift.apk | Select-String "appCategory|isGame"
Output when the flag is present
A: http://schemas.android.com/apk/res/android:isGame(0x010103f4)=true
A: http://schemas.android.com/apk/res/android:appCategory(0x01010545)=0

If you installed Android Build Support through Unity Hub, aapt2 is in the bundled SDK’s build-tools folder; Preferences → External Tools shows where that SDK lives. Android Studio’s APK Analyzer shows the same manifest for APKs and AABs. No output at all means the build isn’t flagged.

Unity 2022.3 phone builds aren’t flagged

Unity 2022.3’s “Android Game” option only appears when Android TV Compatibility is on. In our test, a 2022.3 phone build with that option at its default shipped with neither appCategory nor isGame in its manifest.

Flag it as a game

The fix is one attribute. It’s worth adding even if your cap came from somewhere else: Game Mode, Unity’s Adaptive Performance Android provider, and Android 16’s large-screen rules all depend on it.

Unity 6

  1. Open Edit → Project Settings → Player → Android → Other Settings → Configuration.
  2. Make sure App Category is enabled and set to Game.
  3. Build, then check the manifest as shown in the previous section.

New Unity 6 projects default to Game. Projects upgraded from older versions keep the setting disabled if their old Android Game option was off, so check rather than assume. Some Unity 6 releases don’t have App Category yet (6000.1.15f1, for example); update to a newer patch, or use the manifest route below.

Unity 2022.3, or any version without App Category

  1. Open Player → Android → Publishing Settings and enable Custom Main Manifest. Unity creates AndroidManifest.xml in Assets/Plugins/Android from its template.
  2. Add android:appCategory="game" to the existing <application> tag. android:isGame="true" is optional. Leave the rest of the template as it is.
  3. Rebuild, check the manifest in the built APK, and do a clean install.
Assets/Plugins/Android/AndroidManifest.xml — Unity 2022.3 template plus the game flag
<?xml version="1.0" encoding="utf-8"?>
<manifest
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <application
        android:appCategory="game"
        android:isGame="true">
        <activity android:name="com.unity3d.player.UnityPlayerActivity"
                  android:theme="@style/UnityThemeSelector">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <meta-data android:name="unityplayer.UnityActivity" android:value="true" />
        </activity>
    </application>
</manifest>

Unity 6’s template has a second activity block for GameActivity. If you take the manifest route there, keep the activities Unity generated and change only the <application> tag.

What the attributes mean

appCategory“game” marks an app as primarily a game. Added in API level 26 (Android 8.0). Other values: accessibility, audio, image, maps, news, productivity, social, video
isGameBoolean, added in API level 21, deprecated since API level 26 in favor of appCategory
What Android checksThe GameManager reference accepts either attribute, but the current AOSP Game Mode service checks only the app category — rely on appCategory
Play Console categoryStore settings → App category: application type (app or game) and category. It describes your listing; it doesn’t change the APK
Android 16 large screensFor apps targeting API level 36, orientation and resizability limits are ignored on screens of sw600dp and up — except for games, identified by android:appCategory

Do

  • Flag every build, not just store builds
  • Use App Category in Unity 6 instead of a manifest edit
  • Keep the activity entries Unity generated
  • Verify the flag in the built APK

Don’t

  • Counting on the Play category to reach sideloaded and test builds
  • Pasting a whole manifest from another project
  • Relying on android:isGame alone
  • Assuming an upgraded project kept the setting

Android 16 rule

If your game targets API level 36 and locks its orientation, Android 16 ignores that lock on tablets and foldables unless the manifest says it’s a game. A portrait-only game without the flag can be shown in landscape or resized on a large screen.

Testing and iterating

A fix you can’t measure is a guess. Confirm it with the same probe that found the problem.

  1. Clean install. Uninstall the old build, then install the new one, so you’re testing what a new player gets.
  2. Confirm the flag. On Android 12 and newer, gameMode should now be 1 (standard) or whichever mode the player picked, never 0. Check whether the app now shows up in the phone’s game hub on its own.
  3. Read three reports. With a 60 target on a 60 Hz or 120 Hz panel, expect avgFps at about 60.0 and late at or near 0 in every window outside loading screens.
  4. Run it for 10 minutes. A cap shows from the first report; heat takes time. Watch thermal and whether avgFps sags.
  5. Repeat with battery saver on and on a charger, so you know what players in those states get.
  6. Test more than one maker. One phone each from the families you can borrow — Pixel, Samsung, Xiaomi, OnePlus or OPPO — covers most of the different game-hub behavior.
  7. Test the store path. Upload to an internal testing track in Play Console and install through Play to see the build exactly as players will.
Clean install (bash or PowerShell)
adb uninstall com.example.lanterndrift
adb install LanternDrift.apk
logcat — after the fix (timestamps trimmed)
FrameProbe: targetFps=60 refreshHz=120.0 vSyncCount=1 avgFps=60.0 late=0/300 mainMs=4.2 gpuMs=3.6 powerSave=False thermal=0 gameMode=1
FrameProbe: targetFps=60 refreshHz=120.0 vSyncCount=1 avgFps=60.0 late=0/300 mainMs=4.1 gpuMs=3.6 powerSave=False thermal=0 gameMode=1

That matches the case behind this guide after the fix: an average of 60.0 and 0 of 300 frames late, in every window.

Pro tip

Keep the probe in the project. It switches itself off outside Development Builds, and the next time a tester says the game feels heavy, you’ll have numbers in one build instead of guesses in five.

Checklist

Run through this whenever a mobile build doesn’t hit its frame rate.

Set up

  • Target frame rate: set before the first scene loads (RuntimeInitializeOnLoadMethod or a bootstrap Awake)
  • Target: a rate the panel can show — 60 or 30 on 60 Hz and 120 Hz, 90, 45, or 30 on 90 Hz
  • Frame probe: in the first scene, Development Build enabled

Diagnose

  • targetFps in the log matches what you set
  • avgFps and late frames read from ≥ 3 reports on the phone, not the Editor
  • Busy time (mainMs, gpuMs) compared with the frame budget
  • Optimized Frame Pacing tested off, then turned back on unless it’s the proven cause
  • Battery saver off, charger unplugged, thermal at 0–1, Game Mode not set to battery
  • gameMode ≠ 0 on Android 12 and newer, and the app listed in the phone’s game hub

Fix and verify

  • Flag: App Category = Game (Unity 6) or android:appCategory="game" in a custom main manifest (2022.3)
  • Flag confirmed in the built APK with aapt2 or APK Analyzer
  • Clean install, then avgFps ≈ target and late ≈ 0 across ≥ 3 reports
  • 10-minute run without thermal sag, on phones from ≥ 2 makers
  • Store path tested through a Play internal testing track

Sources

Unity, Android, and phone-maker documentation used in this guide, checked in October 2026:

  • Application.targetFrameRate — Unity Scripting API
  • QualitySettings.vSyncCount — Unity Scripting API
  • Resolution.refreshRateRatio — Unity Scripting API
  • RuntimeInitializeOnLoadMethodAttribute — Unity Scripting API
  • Frame timing API counter reference — Unity Manual
  • Enable frame timing statistics for release builds — Unity Manual
  • Android Player settings (Unity 6) — Unity Manual
  • Android Player settings (2022.3) — Unity Manual
  • PlayerSettings.Android.appCategory — Unity Scripting API
  • Override the Android App Manifest — Unity Manual
  • Android App Manifest — Unity Manual
  • Debug on Android devices — Unity Manual
  • Android Frame Pacing library — Android Developers
  • Frame rate — Android Developers
  • Game Mode API — Android Developers
  • Game Mode interventions — Android Developers
  • FPS throttling — Android Developers
  • About the Game Dashboard — Android Developers
  • GameManager — Android API reference
  • The application manifest element — Android Developers
  • ApplicationInfo (category, CATEGORY_GAME) — Android API reference
  • PackageManager (setApplicationCategoryHint) — Android API reference
  • PowerManager (isPowerSaveMode, getCurrentThermalStatus) — Android API reference
  • Thermal API — Android Developers
  • Behavior changes: apps targeting Android 16 — Android Developers
  • Logcat command-line tool — Android Developers
  • GameManagerService.java — Android Open Source Project
  • Choose a category and tags for your app or game — Play Console Help
  • How to activate and use Game Launcher on your Galaxy phone — Samsung Support
  • How to turn on Game Turbo — Xiaomi Support

Take this guide with you

The full guide as one Markdown file — ready to hand to an AI agent, drop into your notes, or read offline.

Download as MD

On this page

  1. Why exactly 30
  2. The spec sheet
  3. The diagnostic path
  4. Set the target
  5. Measure on device
  6. Read the signature
  7. Rule out frame pacing
  8. Power and heat
  9. Game recognition
  10. Flag it as a game
  11. Testing and iterating
  12. Checklist
  13. Sources
Download as MD