A lane defence game for phones: three lanes, waves of small units walking down them, towers you place and upgrade between waves, a shop with placeholder art in it. Unity 6 on the built-in render pipeline, Android first and iOS after. One developer and a designer built it over four months with Cursor, and by the end of the fourth month it was a real game, with a progression screen and a boss wave and a soundtrack. It ran at sixty frames a second the whole way.
The deliberate setup
There was an architecture in the sense that every prompt produced a MonoBehaviour and every MonoBehaviour got an Update method, so by the fourth month the architecture was fifty-three of those. No assembly definitions, no separation between game state and the objects drawing it, no service layer. That part is ordinary.
The interesting part is that the developer had a performance practice, and had thought about it. The Game view was set to a phone resolution and a phone aspect ratio, so what was on screen matched what would ship. The quality level was turned down and left down, on the reasoning that a phone is not a desktop. The Statistics overlay stayed open in the corner of the Game view, and during every play session there was a frame rate sitting in the top right where it could be seen. It said sixty.
That is more attention than most projects give performance in their first four months, and I want to be fair to it before I take it apart. All of it was measured on a desktop with a discrete GPU, running the editor, which is the one machine the game was never going to ship on.
Then, in the fourth month, somebody installed a build on a phone.
Twenty frames a second, and no target
The phone was a mid-range Android from three years earlier, the cheapest device on the list they intended to support. The first waves were fine. By the fifth wave, with fifty or so units walking and half a dozen towers firing, it was at twenty frames a second and the taps were landing late. On a current flagship the same wave held about thirty-five.
The first theory was that the phone was too old. The second was that Unity is heavy on mobile. Both are reasonable, and acting on either one costs a month.
The first thing I checked was smaller than either. Nobody had ever set a frame rate for the game to hit. Application.targetFrameRate defaults to -1, which hands the decision to the platform, and the platform’s decision on Android and iOS with vSync off is a fixed thirty frames a second to save battery. vSync was off in the quality level they had chosen. So the sixty in the corner of the editor had never been the target on a device, and the number the build was actually failing to reach was thirty.
Everything else took me an afternoon of reading. None of it needed the Profiler: every finding below is visible in the code, on a laptop, with the game not running.
Fifty-three Update methods
Nobody decided there would be fifty-three. I counted them. A prompt that says “make the tower turn to face whatever it’s shooting at” is answered with a script, and a script that has to check something every frame is given an Update. Each one is a defensible answer to the question it was asked. They arrive one a day for four months and nothing in the process ever counts them.
What that looks like in the Profiler has its own write-up: a frame with no single slow function in it, sixty small costs under BehaviourUpdate, and a flat distribution that reads as nothing to find. What follows is the same codebase from the other side, which is what those fifty-three scripts turned out to be doing.
A material per unit
Units are tinted by tier. Bronze, silver, gold, and a red flash when something hits them. The tint is applied when the unit spawns:
void Start()
{
GetComponent<Renderer>().material.color = tierColour;
}
Renderer.material is not a reference to the material on the prefab. Reading it instantiates a copy and makes it unique to that renderer, which the manual states in the first line of the page, along with the consequence: destroying that material when the object goes is your responsibility. Fifty units a wave, a material each, none of them destroyed with the unit that owned it.
The second cost is the one that showed on the device. Unity draws GameObjects in a single instanced call only when they share both the mesh and the material, and after the first frame of a wave no two units shared a material. Fifty identical bronze grunts, fifty draw calls, because changing one colour on each of them had handed each of them a private copy of the same asset.
The supported way to do it is a property block:
static readonly int ColourId = Shader.PropertyToID("_Color");
void Start()
{
MaterialPropertyBlock block = new MaterialPropertyBlock();
block.SetColor(ColourId, tierColour);
GetComponent<Renderer>().SetPropertyBlock(block);
}
The manual describes MaterialPropertyBlock as being for drawing multiple objects with the same material and slightly different properties, which is a fair description of a wave of tiered units. It is also pipeline-specific advice: on the built-in render pipeline, which is what this project is on, a property block is the right answer, and under URP or HDRP it turns off SRP Batcher compatibility for that renderer and the answer is a different one.
A scene search per unit, per frame
Towers pick the nearest unit in their lane. Units pick the nearest tower in front of them. Both were written the same way:
void Update()
{
foreach (Enemy e in FindObjectsByType<Enemy>(FindObjectsSortMode.None))
{
if (InRange(e)) { Attack(e); break; }
}
}
With fifty units and a dozen towers alive, that is sixty-odd scans of the entire scene every frame, each one allocating an array and walking every object in it to find something that was standing two metres away.
The developer told me the first version of it used FindObjectsOfType, which Unity deprecated in 2023.1, so the compiler warned. The warning went back into the chat and came out as the code above: FindObjectsByType, called with FindObjectsSortMode.None, which is the faster of the two options because it skips sorting the results. That is a correct fix to the problem the compiler reported, and the problem the compiler reported was the name of the method. Nothing in a deprecation warning is going to mention that scanning the scene once per unit per frame is a strange way to find something standing two metres away.
The physics timestep
Units are rigidbodies with colliders, which is the ordinary way to build them. Physics runs on its own clock: the Fixed Timestep defaults to 0.02, fifty steps a second, independent of the frame rate.
When a frame takes longer than the timestep, physics is behind, and Unity runs FixedUpdate more than once on the next frame to catch up. The manual is direct about where that goes: a long frame causes multiple FixedUpdate phases in the following frame, which causes another long frame, and so on. What stops it is the Maximum Allowed Timestep, 0.3333 seconds by default, which caps the catching-up at sixteen steps in any one frame. Past that cap Unity stops trying, and game time advances more slowly than real time.
On the device that looked like units slowing down during a heavy wave and speeding up again as it thinned. There was a ticket open for it when I got there: the units stutter and change speed during wave seven, assigned to whoever had last touched the movement code. The movement code was fine. It could not have caused this and could not have fixed it, and two days had gone into reading it.
One canvas, rebuilt every frame
GameManager had grown to eight hundred and forty lines by then: input, wave scheduling, audio triggers, scoring and the HUD, in one class, most of it reachable from one Update. Among the things that Update did every frame was write the player’s gold total into a text field.
Every piece of interface in the game was on a single canvas. The HUD, the wave counter, the tower buttons, the upgrade panel, the pause menu.
Unity rebuilds a canvas as a unit. Change one element on it and the whole canvas is dirty, and the batching is worked out again for everything on it, which is why Unity’s own optimisation guidance is to split canvases up by how often their contents change and keep the static parts away from the moving parts. A gold counter written on every frame makes the entire interface a per-frame cost. The gold total changed maybe forty times in a wave.
Both halves of the fix are ordinary. Write the text when the value changes rather than when the frame ticks, and put the elements that change on a canvas of their own. Neither is available to something answering “show the player’s gold in the top left” as a self-contained request, because the cost is not in that request. It is in the other eight hundred lines and the forty other elements on the canvas.
What the project still lacks
No Profiler capture from a device, or from anything else. No frame rate target written down anywhere, in the project settings or in a document. No pooling for units, projectiles or effects. No LODs on any of the unit models and no per-platform texture overrides, so the phone build ships what the editor imported. No asset preloading: the first appearance of any unit type still reads it off disk with Resources.Load, which loads on the calling thread and returns when it is finished. No CI, no device build, and no way to see a frame time move except by installing an APK by hand and looking at it.
The lesson
Every script in that project was a correct answer. The tint script tints, the targeting script targets, the wave scheduler schedules waves, and none of them contains anything a reviewer would call a bug. The game does what it was asked to do, on a machine that can afford it.
What none of them had was the rest of the game. A tint script that instantiates one material is a script that instantiates one material, and the fiftieth copy of it is the same script: the number fifty appears nowhere in the file. The targeting loop is cheap in the scene it was written against. Frame time is the one property of a codebase that cannot be read out of any single file, because it belongs to all of them running at once, on hardware, in the wave where the game is hardest. That is not in the text files, and reading the text files better does not find it.
The number in the corner of the editor is not it either. It comes from a desktop running an editor, and it gets watched because it is the only number on screen.
What we would do differently
If this codebase were audited for a client, the report would flag these findings, in order:
FindObjectsByType in Update, allocating an array and scanning the scene once per object per frameRenderer.material at spawn, so nothing batches and nothing is destroyed with the object that owns itUpdate methods. String concatenation, LINQ and boxing in the frame loopResources.Load on first spawn of each unit type, at the moment the wave gets harderApplication.targetFrameRate is never set, so the device picks thirty and nobody knows itGameManager of eight hundred and forty lines holding input, wave logic, audio, scoring and UI in one classThe remediation roadmap phases into three stages.
- Stabilise: get a development build onto the oldest supported phone and capture a real profile, set a frame rate target so there is something to fail against, replace the per-frame scene searches with registration (a unit adds itself to a lane list when it spawns), pool the spawned objects, and move the tint to a property block.
- Structure: take the per-frame work out of fifty-three
Updatemethods and give it one owner that ticks units in a loop, move game state out of MonoBehaviours into plain C# so most of those scripts stop needing a frame callback at all, split the canvas, and introduce assembly definitions so the layers hold. - Sustain: a device build in CI reporting median frame time per wave, a written frame budget the build is checked against, and an art pass for LODs and platform texture settings.
The profile and the target come first. Pooling and property blocks are an afternoon each and they are worth doing, but a project that fixes them and still has no device profile and no target has bought itself a faster game with the same problem: nobody can say what it costs, or whether the next feature broke it.
