Before a player fights a boss, opens a loot box, or spends a single dollar in your game, they have to get through one gate: the download. Everything you build is worthless to a player who never finishes installing. This section is about that gate, and why it matters as much as anything covered later in this book.
Two separate forces work against a big download: rules set by the app stores, and plain human patience.
Most phones are not always connected to Wi-Fi (a wireless internet connection from a router, as opposed to the phone's cellular data plan, which usually has a monthly data cap and is slower). App stores protect players from accidentally burning through that data cap, so they cap how big a download can be over cellular before warning the player or blocking it outright:
Cross that line and a slice of players never finish installing. They see a warning, think "I'll do this on Wi-Fi later," close the store page, and most of them never come back to it.
Even without a hard block, every extra 100 MB you add loses you players. Some see the size on the store page and back out before tapping install; some start the download, get bored or run low on storage, and cancel halfway. The exact numbers vary by store, genre, and country, but the shape is always the same: bigger download, fewer completed installs. Here is an illustrative shape (not measured from a real game, just showing the pattern):
Put in plain numbers: if your store page gets 100,000 visits a month and a 300 MB build converts at 80% while a 600 MB build converts at 60%, that is 20,000 more installs a month from the exact same marketing spend, just from trimming the build.
For a live-service game (one that keeps running and changing after launch, with regular content updates instead of shipping once and being done), this is not a one-time problem. Every patch is a chance to grow the build back up, and every patch is a chance to lose players who were about to try the update on a train, on a bus, or on a data plan with a cap. Build size is a recurring cost, not a launch-day checkbox.
You cannot shrink what you cannot see. Before changing anything, find out what is actually inside the build. Unity (and most engines) can produce a build report: a breakdown of the final build, listing every category of content and how many bytes it costs after compression.
In Unity, after a build finishes, the Editor prints (and the Build Profile / Build Report window shows) something like this:
Build Report - MyGame v1.4.2 (Android, arm64)
------------------------------------------------
Textures 412.6 MB (61%)
Audio 168.3 MB (25%)
Meshes 41.0 MB ( 6%)
Animation Clips 22.4 MB ( 3%)
Scripts (IL2CPP code) 9.8 MB ( 1%)
Fonts & UI Sprites 8.1 MB ( 1%)
Shaders 6.2 MB ( 1%)
Misc / Settings 3.1 MB ( 0%)
------------------------------------------------
Total (compressed) 671.5 MB
Read it the same way every time: sort by size, look at the top two or three categories first, and ignore the rest until those are under control. In this report, Textures and Audio together are 86% of the build. Scripts — the actual compiled game code — is 9.8 MB, under 2%. That is normal. Compiled code is dense (a lot of behavior packed into few bytes); art and sound are not. A build report that shows code as the biggest line item almost always means something else went wrong (like an entire debug library or a huge embedded database accidentally shipping as "code").
Addressables (a Unity system covered in section 7) has its own report, the Build Layout Report, which breaks size down per content bundle instead of per build, so you can see which specific pack of downloadable content grew.
A build report tells you what is big. It does not tell you why something ended up in the build at all. That second question matters because engines include assets by reference, not by intention: if anything in your build — a scene, a prefab, a script's public field — points at an asset, that asset gets packed in, whether or not a player will ever actually see it.
A common way this happens: someone drags a large piece of reference art into a field during a design review, and forgets to remove it.
public class PlayerHud : MonoBehaviour
{
// Someone dragged a 4096x4096 concept-art screenshot in here
// during a design review, then forgot to remove it.
public Texture2D debugReferenceArt;
public Sprite healthIcon;
public Sprite staminaIcon;
}
No code in the game ever reads debugReferenceArt. It never appears on screen. But because it is a public field with something assigned to it in the Inspector, the build system sees a live reference and packs the whole 4096x4096 image in anyway. Remove that one reference, rebuild, and the report might shrink like this:
Before: Textures 412.6 MB (61%)
After: Textures 380.1 MB (58%)
(one accidental 32 MB reference, gone)
The second, bigger version of this trap is Unity's special Resources folder. Anything placed inside a folder literally named Resources gets packed into the build in its entirety, whether or not it is ever loaded at runtime — because the engine cannot know ahead of time what string a piece of code might pass to Resources.Load at runtime, so it plays it safe and keeps everything.
Resources as a junk drawer for "stuff I might need." On a live-service game that ships new content every few weeks for a year or more, a Resources folder quietly grows and grows, and nobody notices until a build report shows it is 200 MB of art nobody has referenced in six months. Prefer explicit references or Addressables (section 7) so unused content can actually be identified and dropped.To catch these before they ship: search for what actually references a suspiciously large asset (most engines have a "Find References In Scene/Project" tool), and run an unused-asset analysis pass before each major build. Catching one accidental 32 MB reference is worth more than an afternoon of careful texture compression tuning.
Textures were the single biggest line in the build report above, and on almost every real game, they are. Two levers control their size: compression format, and max size.
A texture compression format is a way of encoding image data so it takes less space, using math the GPU (graphics processing unit, the chip that draws pixels) can unpack extremely fast, directly in hardware. This is different from something like a JPG, which must be fully decompressed into an uncompressed image in memory before it can be drawn. Common formats:
The "max size" import setting caps how large a texture is allowed to be in the final build, regardless of how large the source file is. A texture drawn as a 64x64 icon on screen gains nothing from being stored as a 4096x4096 source file — it is just wasted bytes and wasted GPU memory.
// Editor script: force textures down to 1024 max, ASTC 6x6 on mobile.
using UnityEditor;
using UnityEngine;
public class TextureShrinker : AssetPostprocessor
{
void OnPreprocessTexture()
{
TextureImporter importer = (TextureImporter)assetImporter;
TextureImporterPlatformSettings mobile =
importer.GetPlatformTextureSettings("Android");
mobile.overridden = true;
mobile.maxTextureSize = 1024;
mobile.format = TextureImporterFormat.ASTC_6x6;
mobile.compressionQuality = 50;
importer.SetPlatformTextureSettings(mobile);
}
}
Worked example: ui_background.png starts as a 4096x4096, 32-bit source PNG, about 6.4 MB on disk before import. After the settings above (capped at 1024, ASTC 6x6), it lands in the build at roughly 220 KB — about 29 times smaller — with no visible quality loss for a background that never fills more than a quarter of a phone screen.
Audio was the second-biggest category in the sample build report. Two changes usually cut it down the most: compression format, and switching sound effects to mono.
Raw, uncompressed audio (PCM WAV) is large: roughly 10 MB per minute for CD-quality stereo sound. A compressed format like Vorbis re-encodes that same audio using far fewer bytes, at a quality setting you choose, with a small, usually inaudible, quality trade-off. For almost all game audio — sound effects, ambience, most music — the size savings are worth it.
Stereo audio stores two channels (left and right); mono stores one, at roughly half the file size. Most sound effects — footsteps, UI clicks, weapon impacts — do not need stereo width, because 3D positional audio (sound that seems to come from a specific point in the game world) is rendered from a mono source and panned left/right by the engine at runtime anyway. Forcing sound effects to mono is close to a free size cut.
using UnityEditor;
using UnityEngine;
public class AudioShrinker : AssetPostprocessor
{
void OnPreprocessAudio()
{
AudioImporter importer = (AudioImporter)assetImporter;
if (assetPath.Contains("/SFX/"))
{
importer.forceToMono = true;
}
AudioImporterSampleSettings settings = importer.defaultSampleSettings;
settings.compressionFormat = AudioCompressionFormat.Vorbis;
settings.quality = 0.5f; // 0 = smallest file, 1 = best quality
settings.loadType = AudioClipLoadType.CompressedInMemory;
importer.defaultSampleSettings = settings;
}
}
Worked example: footstep_grass.wav starts as stereo, 16-bit, 44.1 kHz, 2.1 seconds long — about 370 KB uncompressed. Forced to mono and compressed with Vorbis at quality 0.5, it drops to roughly 41 KB, about 9 times smaller, with no audible difference for a one-shot sound effect.
Code was only about 2% of the sample build report, but it is not free, and cleaning it up helps more than just download size — it also helps startup time and patch size.
When Unity builds with IL2CPP (a step that converts C# into C++ and then into native machine code), a setting called Managed Stripping Level controls how aggressively the build tool removes C# methods and classes that static analysis proves are never called. There is also a related idea at the engine level: Unity ships in modules (Physics2D, Terrain, Video, XR, and others), and unused ones can be excluded from the native binary through the Package Manager and engine code stripping settings, so a 2D-only mobile game does not carry 3D terrain engine code it will never call.
The same asset can end up packaged more than once without anyone deciding that on purpose. A classic case: two feature teams each import their own copy of the same icon into their own folder, instead of sharing one.
This also happens at the content-bundle level (see section 7): if two separate downloadable bundles both depend on the same texture and neither is set up to share a common dependency bundle, the engine has no choice but to pack that texture into both, and a player who downloads both bundles pays for it twice. Addressables has a built-in "Check duplicate bundle dependencies" analysis rule for exactly this, and it is worth running before every content release, not just once at project setup.
Everything so far shrinks what you already have. This section is about not shipping some of it at all, at least not up front.
Instead of one build containing every level, every event, every costume the game will ever have, a live-service game usually ships a small initial download that gets the player into the main menu fast, and fetches the rest — new levels, seasonal event content, voice packs for languages the player did not choose, and so on — as downloadable content (often shortened to DLC), only when it is actually needed.
A few concrete systems implement this idea:
Notice the player is looking at a playable main menu before the 300 MB chapter has even started downloading. That gap between "installed" and "actually playing" is exactly the gap section 1's drop-off numbers punish — splitting content this way shrinks that gap dramatically, even though the total amount of data downloaded over the game's lifetime might end up similar or even larger.
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
public class ChapterLoader : MonoBehaviour
{
AsyncOperationHandle<GameObject> handle;
public async void LoadChapterBoss(string address)
{
// "address" is a string key, e.g. "Chapter1/BossPrefab".
// It can point at a local file or a remote bundle -
// the caller does not need to know which.
handle = Addressables.LoadAssetAsync<GameObject>(address);
GameObject bossPrefab = await handle.Task;
Instantiate(bossPrefab);
Debug.Log($"Loaded {bossPrefab.name}, from {address}");
}
public void UnloadChapterBoss()
{
// Release tells Addressables this handle is no longer needed.
// Once nothing else references the underlying bundle,
// Addressables is free to unload it from memory, and (for
// remote bundles) eventually evict it from local disk cache.
if (handle.IsValid())
{
Addressables.Release(handle);
}
}
}
Loaded Boss_Chapter1, from Chapter1/BossPrefab
When LoadChapterBoss runs, Addressables resolves the string "Chapter1/BossPrefab" to wherever that content actually lives, downloads it if needed (or reads it from local cache if it was already fetched), and hands back a usable prefab once ready. The await means this happens without freezing the game while the download or disk read completes.
LoadAssetAsync and call Release on them when you are done. Addressables reference-counts loaded content; forgetting to release a handle is one of the most common ways a Unity game's memory usage quietly climbs over a long play session.Downloadable content (section 7) decides whether data is on the device at all. Streaming decides whether it is in memory (RAM) right now, even for content that is already fully downloaded and sitting on disk. An open world can easily be far bigger than what fits in memory at once, so the game keeps only a window of it loaded around the player, and swaps pieces in and out as they move.
Two rules make this feel seamless instead of jarring:
The area kept in memory is bounded no matter how large the total world is. If each chunk is 100m x 100m and the load radius is 3 chunks, the game only ever holds a 700m x 700m area in memory around the player, whether the full world is 2 km wide or 200 km wide.
In Unity, a scene normally replaces whatever was loaded before it — that is LoadSceneMode.Single, the default. Additive loading, LoadSceneMode.Additive, instead adds a scene's contents on top of whatever is already loaded, without unloading anything else. This is the usual way streaming chunks (section 8) get implemented in Unity: each chunk is its own small scene, loaded and unloaded independently as the player moves.
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
public class ChunkStreamer : MonoBehaviour
{
public Transform player;
public float chunkSize = 100f;
public int loadRadius = 1; // chunks kept loaded around the player
readonly HashSet<Vector2Int> loadedChunks = new HashSet<Vector2Int>();
void Update()
{
Vector2Int current = WorldToChunk(player.position);
// Load every chunk within loadRadius of the player.
for (int dx = -loadRadius; dx <= loadRadius; dx++)
{
for (int dz = -loadRadius; dz <= loadRadius; dz++)
{
Vector2Int coord = new Vector2Int(current.x + dx, current.y + dz);
if (!loadedChunks.Contains(coord))
{
LoadChunk(coord);
}
}
}
// Unload anything that fell outside the radius.
var toUnload = new List<Vector2Int>();
foreach (Vector2Int coord in loadedChunks)
{
int distance = Mathf.Max(Mathf.Abs(coord.x - current.x),
Mathf.Abs(coord.y - current.y));
if (distance > loadRadius)
{
toUnload.Add(coord);
}
}
foreach (Vector2Int coord in toUnload)
{
UnloadChunk(coord);
}
}
Vector2Int WorldToChunk(Vector3 pos)
{
return new Vector2Int(
Mathf.FloorToInt(pos.x / chunkSize),
Mathf.FloorToInt(pos.z / chunkSize));
}
void LoadChunk(Vector2Int coord)
{
string sceneName = $"Chunk_{coord.x}_{coord.y}";
loadedChunks.Add(coord);
SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);
Debug.Log($"Loading {sceneName}");
}
void UnloadChunk(Vector2Int coord)
{
string sceneName = $"Chunk_{coord.x}_{coord.y}";
loadedChunks.Remove(coord);
SceneManager.UnloadSceneAsync(sceneName);
Debug.Log($"Unloading {sceneName}");
}
}
Say the player has been standing still near chunk (1, 0) with loadRadius = 1, then walks briskly to the right, crossing into chunk (2, 0). The next few frames log something like this:
Loading Chunk_2_0
Loading Chunk_2_1
Loading Chunk_2_-1
Unloading Chunk_-1_0
Unloading Chunk_-1_1
Unloading Chunk_-1_-1
Chunks 0 and 1 (in the middle) are still inside the radius on both sides of the crossing, so they never appear in either log — loadedChunks.Contains(coord) is already true for them, so LoadChunk is skipped, and they are within loadRadius of the new position, so UnloadChunk is skipped too. Only the chunks that actually entered or left the radius do any work. SceneManager.LoadSceneAsync and UnloadSceneAsync both spread their work over multiple frames rather than freezing the game for one long frame, which is what makes streaming feel smooth instead of causing a stutter every time the player crosses a chunk boundary.
distance > loadRadius on every single frame, constantly loading and unloading the same chunk. A common fix is a small buffer — unload only once the player is loadRadius + 1 chunks away, not exactly loadRadius — so crossing back and forth near a border does not cause thrashing.No matter how well content is compressed and streamed, loading something new always takes some amount of time greater than zero. The goal is not to make that time disappear — it is to make sure the player never has to sit and stare at a stalled screen waiting for it.
The ChunkStreamer from section 9 already gets this almost for free: LoadSceneAsync is asynchronous by nature, so as long as the load radius is generous enough relative to the player's speed, the chunk the player is walking into finishes loading well before they arrive, entirely hidden behind normal gameplay.
A live-service game patches every few weeks. If every patch re-sent the entire build just to fix one weapon's damage number, players on slow connections would fall further behind with every update, and server and CDN costs (section 12) would balloon for no reason.
A delta update (or diff patch) compares the old version of a file to the new version and sends only the bytes that actually changed, rather than the whole file again. The client already has the old file; it applies the small difference to what it already has, instead of downloading everything from scratch. At the level of downloadable content bundles (section 7), this usually means: only bundles that actually changed get re-downloaded at all — untouched bundles are left alone completely.
To know which bundles changed, every bundle is tagged with a version number and, usually, a content hash (a short fingerprint of the file's exact contents — if even one byte changes, the hash changes too). Client and server compare a small manifest listing every bundle's current hash, and only fetch the bundles whose hash does not match what the client already has.
{
"Chunk_2_0": { "version": 7, "hash": "a91f3d", "size_kb": 820 },
"Chunk_2_1": { "version": 3, "hash": "77c210", "size_kb": 640 },
"WeaponBalanceTable": { "version": 41, "hash": "0e5b9c", "size_kb": 4 }
}
Worked trace: a client currently has WeaponBalanceTable at hash 5f21aa, and the two chunk bundles above already matching what the server lists. The server's latest manifest says WeaponBalanceTable is now at hash 0e5b9c. The client compares all three hashes, finds only WeaponBalanceTable differs, and downloads just that one 4 KB file — the 1.4 MB of chunk data that did not change is never re-transferred.
This matters especially for a live-service game because most patches are balance and data tweaks, not new art — exactly the kind of change that should cost kilobytes, not gigabytes, if the patching system is set up to notice that nothing else moved.
Once content is split into small, independently versioned pieces, something has to serve those pieces to millions of players around the world, fast, at the same time a patch goes live. That is the job of a CDN (Content Delivery Network): a set of servers, called edge nodes, spread across many regions, each holding a copy of your content files.
Instead of every player's download traveling to one central server on the other side of the planet, each player's request is served from a nearby edge node. This gives three benefits at once: lower latency (the time before data starts arriving), higher combined bandwidth (thousands of players downloading a patch at once are spread across many machines instead of overloading one), and resilience (if one edge node has trouble, requests can be routed to another).
A typical setup: the build pipeline uploads new content bundles to an origin storage location, the CDN copies them out to edge nodes, and the game's remote catalog (the manifest from section 11) simply points at CDN URLs. Because the client only cares about the URLs and the hashes, moving to a different CDN provider, or adding more edge regions, does not require shipping a new version of the game at all.
Office and home Wi-Fi is unusually fast and unusually stable compared to what a lot of real players have: a train tunnel losing signal mid-download, public Wi-Fi shared by two hundred other phones, or a cellular connection that drops for a few seconds at a time. A download flow that works perfectly on a fast, stable connection can fail badly on a real one — stalling forever, retry-looping, or leaving a half-written file behind — and that bug will not show up in normal testing unless someone deliberately goes looking for it.
Ways to actually test this instead of hoping for the best:
using System;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
public static class DownloadRetry
{
public static async Task DownloadWithRetry(string address, int maxAttempts = 3)
{
int attempt = 0;
while (true)
{
attempt++;
var handle = Addressables.DownloadDependenciesAsync(address);
await handle.Task;
if (handle.Status == UnityEngine.ResourceManagement
.AsyncOperations.AsyncOperationStatus.Succeeded)
{
Debug.Log($"Downloaded {address} successfully");
Addressables.Release(handle);
return;
}
Addressables.Release(handle);
Debug.Log($"Download failed (attempt {attempt}/{maxAttempts})");
if (attempt >= maxAttempts)
{
throw new Exception($"Could not download {address} after {maxAttempts} attempts");
}
await Task.Delay(1000 * attempt); // simple backoff: wait longer each retry
}
}
}
Download failed (attempt 1/3)
Download failed (attempt 2/3)
Downloaded Chapter1/BossPrefab successfully
The connection drops twice, and the loop waits a little longer after each failure (1 second, then 2 seconds) before trying again, instead of hammering a bad connection at full speed or giving up instantly. On the third attempt it succeeds and returns normally. Without this kind of retry logic, the very first hiccup on a bad connection would either crash the download entirely or leave the player stuck on a stalled progress bar forever.
Textures 210.0 MB (40%)
Audio 120.0 MB (23%)
Fonts & UI Sprites 95.0 MB (18%)
Meshes 60.0 MB (11%)
Scripts (IL2CPP code) 8.0 MB ( 2%)
Misc 35.0 MB ( 6%)
------------------------------------------------
Total (compressed) 528.0 MB
On every earlier build of this project, Fonts & UI Sprites was under 10 MB. Nobody remembers adding 85 MB of new UI art recently. What is the most likely cause, and what would you actually do, in order, to find and fix it?95 MB for fonts and UI sprites, jumping from a normal <10 MB, is almost certainly not real UI work — it has the shape of an accidental inclusion (section 3), not intentional growth. The likely cause is a large non-UI texture (a debug overlay, a full-screen reference screenshot, a leftover design mockup) that got imported and is being classified or bundled alongside the UI sprite atlas, or a public field somewhere still referencing something like that.
Steps to actually find it:
debugReferenceArt example in section 3) and rebuilding to confirm the category drops back toward its normal size.Resources folder, treat that as the deeper problem — anything in Resources ships whether referenced or not, so the real fix is moving it out of Resources entirely, not just removing one reference.ChunkStreamer from section 9 uses the same loadRadius value for both loading and unloading, which section 9's warning box flagged as causing reload/unload thrashing when a player stands near a chunk border. Modify the class to use a separate, larger radius for unloading, so a chunk is only freed once the player is clearly outside the loaded area, not the instant they cross it.The fix is to add a second radius field, strictly larger than loadRadius, and use it only in the unload check — the load loop still uses loadRadius unchanged:
public class ChunkStreamer : MonoBehaviour
{
public Transform player;
public float chunkSize = 100f;
public int loadRadius = 1;
public int unloadRadius = 2; // must be > loadRadius to add a buffer
readonly HashSet<Vector2Int> loadedChunks = new HashSet<Vector2Int>();
void Update()
{
Vector2Int current = WorldToChunk(player.position);
for (int dx = -loadRadius; dx <= loadRadius; dx++)
{
for (int dz = -loadRadius; dz <= loadRadius; dz++)
{
Vector2Int coord = new Vector2Int(current.x + dx, current.y + dz);
if (!loadedChunks.Contains(coord))
{
LoadChunk(coord);
}
}
}
var toUnload = new List<Vector2Int>();
foreach (Vector2Int coord in loadedChunks)
{
int distance = Mathf.Max(Mathf.Abs(coord.x - current.x),
Mathf.Abs(coord.y - current.y));
if (distance > unloadRadius) // was loadRadius - this was the bug
{
toUnload.Add(coord);
}
}
foreach (Vector2Int coord in toUnload)
{
UnloadChunk(coord);
}
}
Vector2Int WorldToChunk(Vector3 pos)
{
return new Vector2Int(
Mathf.FloorToInt(pos.x / chunkSize),
Mathf.FloorToInt(pos.z / chunkSize));
}
void LoadChunk(Vector2Int coord)
{
string sceneName = $"Chunk_{coord.x}_{coord.y}";
loadedChunks.Add(coord);
SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);
}
void UnloadChunk(Vector2Int coord)
{
string sceneName = $"Chunk_{coord.x}_{coord.y}";
loadedChunks.Remove(coord);
SceneManager.UnloadSceneAsync(sceneName);
}
}
With loadRadius = 1 and unloadRadius = 2, a chunk at distance 2 stays loaded even though it is outside the load radius — it just will not be re-triggered for loading, because it is already in loadedChunks. Only once the player moves far enough that a chunk is at distance 3 does it actually get unloaded. A player wiggling back and forth across the boundary at distance 1-to-2 no longer causes any loading or unloading at all, which is the whole point of the buffer.
WeaponBalanceTable, which is 4 KB, and nothing else. Using the version-manifest idea from section 11: (a) explain, step by step, how the client figures out that only this one bundle needs to be re-downloaded, and (b) roughly how much data transfers in this patch with delta/versioned patching, versus without it.(a) How the client figures out what changed:
WeaponBalanceTable, is queued for download, since a changed hash means its contents changed.(b) Data transferred: