Skip to content

Performance

Four things decide whether your game runs well on a phone: how much memory your textures take, whether you create objects during gameplay, whether your scripts allocate every frame, and whether you can see any of it happening.

This page covers all four. If you only read one section, read Object pooling — it is the one that turns a stuttering action game into a smooth one.

Texture compression

Textures dominate memory in a 2D game. Six 2048×2048 sprite atlases cost about 100 MB of video memory uncompressed. Compressed, the same artwork costs about 25 MB.

You do not have to do anything to get this. Every texture is compressed when you export a build, using a format the target device can sample directly. The editor always loads your original file, so nothing you see in the viewport changes — only what ships.

When to override it

Select a texture in the Content Browser and look at Import Settings → Compression in the Properties panel.

SettingWhat it doesUse it for
AutoBalanced. About 9× smaller than uncompressed on mobile.Everything, unless one of the rows below applies.
NoneShips your original file untouched.Pixel art, logos, hard-edged UI, anything with text baked into it.
Quality4× smaller — the least lossy compressed option. Slower to encode.Art that visibly smears on Auto but doesn't need to ship raw.
SizeAbout 16× smaller.Large, soft artwork: backgrounds, gradients, smoke, clouds.

Block compression is lossy. It is invisible on most artwork and obvious on some — look at your own art rather than trusting the setting. A logo that comes out fringed wants None; a sky gradient can usually take Size with nothing to see.

Quality and Size are mobile-only

Desktop and older Android use fixed-rate formats with no block-size choice, so all three compressed settings encode identically there. The setting is honoured where it means something and ignored where it doesn't.

Why your first export is slow and the second isn't

Encoding is genuinely expensive — seconds per texture. Results are cached by content, so:

  • Re-exporting with nothing changed re-encodes nothing.
  • Changing one texture re-encodes that one.
  • Switching branches doesn't invalidate the cache (it keys on file contents, not timestamps).

The cache lives in .joystick/TextureCache/ inside your project. Deleting it is always safe; it just costs you one slow export.

Object pooling

Creating an entity costs an entity, its components, a physics body, and a C# object. Doing that twenty times a second for bullets means sustained churn in three allocators at once, and on a phone you feel it.

A pool pays that cost once, up front, and then hands the same entities out forever.

Setting one up

  1. Put one bullet in your scene, set up exactly how you want spawned bullets to look.
  2. Name it Bullet. The pool's name is the template entity's name.
  3. Create the pool from a script.
csharp
public class Gun : GameEntity
{
    private Pool m_Bullets;

    protected override void OnCreate()
    {
        m_Bullets = Pools.Create("Bullet", prewarm: 64, growth: PoolGrowth.Double, max: 512);
    }

    private void Fire(Vector3 muzzle)
    {
        GameEntity? bullet = m_Bullets.Get();
        if (bullet == null)
            return;                 // pool is at its cap — see below

        bullet.Translation = muzzle;
    }
}

The engine disables your template for you, and never hands the template itself out — you won't get a stray bullet parked at the origin.

prewarm builds instances immediately, which is the entire point: you pay for 64 bullets during a load screen instead of during a firefight.

Returning them

csharp
m_Bullets.Release(bullet);

Releasing is safe to call on something that isn't pooled, or that was already released — both happen in real games where two systems each decide a bullet is finished.

The one thing that will bite you

Your state is not reset for you

A pooled entity keeps everything from its previous life: velocity, animation frame, timers, and every field on your script. A recycled bullet that arrives already moving is not a bug in the engine — it is an empty OnDespawn.

Pooled entities are deactivated, not destroyed, so OnCreate and OnDestroy fire once each for the whole life of the instance. The hooks that fire on every reuse are OnSpawn and OnDespawn:

csharp
public class Bullet : GameEntity
{
    private float m_Lifetime;

    protected override void OnSpawn()
    {
        m_Lifetime = 3.0f;          // start of life
    }

    protected override void OnDespawn()
    {
        // Clear anything that would carry into the next life.
        Rigidbody2DComponent? body = GetComponent<Rigidbody2DComponent>();
        if (body != null)
            body.LinearVelocity = Vector2.Zero;
    }
}

OnDespawn runs before the entity is disabled, so physics and audio are still live there — zeroing a velocity works.

Growth and the cap

growthBehaviour
PoolGrowth.FixedNever grows. Get() returns null once everything is in use.
PoolGrowth.LinearAdds its prewarm count again each time it runs dry.
PoolGrowth.DoubleDoubles. The right default for bullets.

Every pool has a hard max. This is deliberate: an unbounded pool under a runaway spawn bug is an out-of-memory kill on a phone, while a capped one is a visible stutter and one log line telling you which pool it was.

A frame hitch can grow your pool

If you spawn at a rate — credit += rate * ts — then one slow frame emits a burst, and nothing can be released yet because none of those objects have reached the end of their lifetime. In-flight briefly spikes and the pool grows.

This is measured, not hypothetical: a 500/second spawner with a 0.1 s lifetime sits at ~52 in flight forever, but doubled its pool once during Play-mode startup. Either prewarm above your worst-case burst, or clamp the spawns you allow in a single frame:

csharp
int spawnsThisFrame = 0;
while (m_Credit >= 1.0f && spawnsThisFrame < 16)
{
    m_Credit -= 1.0f;
    spawnsThisFrame++;
    // ... spawn ...
}

Handle Get() returning null — it means you hit the cap. You can see it coming:

csharp
if (m_Bullets.AtCap)
    Log.Warn($"Bullet pool exhausted: {m_Bullets.InUse}/{m_Bullets.Max}");

Free, InUse, Capacity, Max, and AtCap are all readable at any time.

Scenes and pools

Pools are cleared when their scene unloads. To keep one across a scene change, call Persist() on the template entity before creating the pool — the pool then lives wherever its template lives.

Pooled entities freeze with everything else when Time.TimeScale is 0.

Allocation discipline

Every C# object you create feeds the garbage collector. On desktop you may never notice; on a Mono/AOT phone, a collection is a visible hitch. The goal for steady-state gameplay is simple and achievable: zero bytes allocated per frame.

Five rules get you there.

1. Don't build strings in OnUpdate

This is the single most common cause, and it hides in plain sight:

csharp
// ✗ allocates a new string every frame — 60 per second, from one label
m_Label.Text = $"{m_Score}";

// ✓ allocates nothing
m_Label.SetInt(m_Score);

SetInt and SetFloat pass the number to the engine and let the engine format it, so nothing is allocated on the C# side at all.

csharp
protected override void OnUpdate(float ts)
{
    m_ScoreLabel.SetInt(m_Score);
    m_TimerLabel.SetFloat(m_TimeLeft, digits: 1);
}

2. Reuse buffers instead of taking new collections

Any API that hands you back a fresh array or list allocates one per call. Where the engine offers a version that fills a buffer you supply, use it, and keep the buffer in a field rather than creating it in OnUpdate.

3. Cache your component lookups

GetComponent<T>() is cheap, but calling it several times a frame is still work you can do once in OnCreate.

4. Prefer structs for small values

Vector2, Vector3, Vector4 and Pool are all structs — passing them around allocates nothing. Keep your own small value types the same way.

5. Watch out for closures

A lambda that captures a local variable allocates. In a per-frame path, hoist it or avoid it.

Seeing it happen

Rules you can't measure are rules nobody follows, so the numbers are readable from script in a development build.

csharp
protected override void OnUpdate(float ts)
{
    if (Profiler.AllocatedBytesThisFrame > 0)
        Log.Warn($"allocating {Profiler.AllocatedBytesThisFrame} bytes/frame");
}
PropertyWhat it tells you
Profiler.AllocatedBytesThisFrameBytes the managed heap grew during the last frame. Drive this to 0.
Profiler.GCCountCollections since startup. How fast it climbs during gameplay is what matters.
Profiler.AllocationBudgetBytesPerFrameSet it, and the engine logs a warning naming the worst-offending script.
csharp
// Warn me if any frame allocates more than 4 KB.
Profiler.AllocationBudgetBytesPerFrame = 4096;

The warning names the script class responsible, which is usually enough to find the offending line immediately.

For total memory rather than per-frame churn, Memory reports what the engine itself is holding:

csharp
Log.Info($"textures: {Memory.UsedBy(MemoryCategory.Textures) / (1024 * 1024)} MB");
Log.Info($"peak: {Memory.Peak / (1024 * 1024)} MB");

The memory budget

Placeholder: the profiler's memory breakdown, showing usage per category against the device budget

MemberWhat it tells you
Memory.UsedWhat the engine is holding right now
Memory.UsedBy(category)The same, broken down — textures, audio, and the rest
Memory.PeakThe highest it has been this session. ResetPeak() starts a fresh measurement
Memory.AvailableHow much headroom is left
Memory.BudgetThe ceiling, where the platform reports one
Memory.DriverUsedWhat the graphics driver says it's using, as a cross-check
Memory.IsUnifiedWhether RAM and video memory are the same pool (they are on phones and on Apple silicon)
Memory.PollPressure()Whether the OS is currently asking for memory back

On a phone, Available is the number that matters — it's what the OS watches, and running out is a kill, not a slowdown.

csharp
if (Memory.PollPressure())
    DropOptionalAssets();

A good habit while building levels: load a scene, unload it, and watch Memory.Used come back to roughly where it started. If it climbs every cycle, something isn't being released — usually an entity that never gets destroyed, or an asset still referenced by a script that outlived its scene.

Development builds only

The allocation counters, the budget warning, and the profiler zones are all compiled out of a Release build. You can leave the calls in your shipping code — they cost nothing there. Memory is the exception: it works in every build.

A checklist before you ship to a phone

  • Textures set to None only where you actually looked and saw a problem.
  • Anything you spawn during gameplay — bullets, enemies, particles, damage numbers — comes from a pool.
  • Every pooled script has an OnDespawn that clears its state.
  • No $"..." anywhere in an OnUpdate.
  • Profiler.AllocatedBytesThisFrame reads 0 while you stand still doing nothing.
  • Your export log's size breakdown doesn't contain anything surprising.