Game Code Optimization for Beginners
Two optimization techniques all game developers should know
Tips, pointers, and examples for simple yet very effective techniques that you can apply to almost any kind of game. They're dumb-simple, and they work.
You know what they say about "premature optimization". In general, it's considered bad if you let optimization direct your design, since it can result in code that is much less clean and thus harder to modify and understand months later in development.
There are however, in my view, some techniques that are so prevalent and fundamental to all other systems in many games, that they are worth considering from the start. I haven't found any posts describing these, so hopefully some of you will find this useful!
Disclaimer
As always, the code in this article is just one example of a simple implementation. It is by no means a full implementation and you'll likely have to adjust to fit into your project.
My intention here is to give you a head-start. The techniques described here are focused on keeping data structured, caching and reducing the frequency of calculations.
This is a follow-up to my real-time strategy post, but again — as I argue, many real-time strategy systems form the foundation for many other genres.
Use a Profiler
Before we even start, I suggest that you use a profiler.
Seriously, do it. In my early game development days it took a while before I started using one. I regret that. Most of the tips in this article are based on experiences that began when I made my real-time strategy game in the XNA Framework more than 10 years ago, without a profiler. I was simply comparing different solutions based on FPS and manually adding timers to code. A cumbersome and unreliable way of optimizing. As soon as you start caring about performance, fire up your profiler. It won't take long to learn, and will save you countless hours in the end.
All big game engines have one. For Unity, that'd be the Unity Profiler, for Godot The Profiler, and for Unreal engine Unreal Insights.

The general purpose of a profiler is to allow you to record and analyze exactly what is going on in your game frame-per-frame, easily distinguishing choke-points and areas to focus your optimization efforts on.
Divide and Conquer
The first technique is about keeping data relevant to one another close. I'll start with an example. Regardless of whether you're making a real-time strategy (RTS) game, action role playing (ARPG) game, or multiplayer online battle arena (MOBA), you're certainly working with a lot of agents. Usually, these agents need to know about other agents around them, for example when searching for hostile targets or applying buffs to friendlies.
In an early prototype one would probably do something simple like looping through all possible agents, checking the distance between their positions and returning the nearest one. Not a problem with few agents, but as maps and the number of agents grows, it will probably become one.
Thinking about this in a "realistic" sense could help here, as if trying to visualize the logic of a real situation, say a medieval battle. It does not make much sense for an individual soldier to think or even consider the hostile soldiers on the hill in the distance when deciding whose attack to parry, or who to attack next.
Of course, a lot goes on in the mind of an individual soldier, but the brain has an amazing capacity of easily filtering out information which it does not regard vital. In a way, that's what we'll be doing here, too.
If you followed my directions in the previous post, you might have a tile system now — they are easy to work with. The idea is that whenever an agent enters a new tile, we add it to a list belonging to that tile, whenever it leaves (or dies!) we remove it, and add it to the eventual new tile.
We're practically caching which tile agents stand on, instead of figuring this out every time we need to.
The Simplest Example of Caching in Unity
In Unity, you're most likely doing GameObject.Find
somewhere. This searches through all GameObjects which gets increasingly costly as your scene grows.
Avoid calling this frequently.
Say you are interested in a player object which never changes. You should call GameObject.Find
once in the Awake
or Start
method,
and store the result to a variable instead.
Unity provides examples on how to do this here.
For objects that change, work out a way to figure out when it changes, without doing a search. It would make sense to use events for this. A simpler way would be to call a static function.
My preferred solution to do this is with a getter-setter on the Position variable, to ensure we never accidentally miss doing this, which would lead to less fun bugs.
public class Agent {
// You might be using a Vector2, Vector3 or Point here.
// Basically anything that holds coordinates.
public override FPoint Position
{
get
{
return base.Position;
}
set
{
Tile newTile = World.TileContainingPosition(value);
// Only update position if the new is within world bounds.
if (newTile != null)
base.Position = value;
// If the agent is still on the same tile, do nothing more.
if (CurrentTile == newTile)
return;
CurrentTile?.AgentLeaves(this);
CurrentTile = newTile;
CurrentTile.AgentEnters(this);
}
}
// We call this when the Unit dies, or enters a building / transport.
public void DropTile()
{
CurrentTile?.AgentLeaves(this);
CurrentTile = null;
}
}
Then, in the Tile class, AgentEnters(Agent agent)
and AgentLeaves(Agent agent)
handle adding and removing the agent from its list,
and eventual other logic we might want to run, such as Fog of War.
Now, whenever an agent needs to search for enemies in a 30m radius and pick the nearest one, we can go through the agent lists of those nearby tiles. In many cases that might result in 0 distance checks, and probably never or at least very seldom distance checks against all agents.
Here's how the relevant piece of the Tile class might look like:
public class Tile {
public void AgentEnters(Agent agent) { ... }
public void AgentLeaves(Agent agent) { ... }
private readonly List<Agent> agents = new List<Agent>();
public List<Agent> GetNearbyAgents(int squareRange)
{
// A tile range less than 2 does not make sense,
// since we would only get agents in this exact tile
// even if the distance to another agent in the other tile is 0.1m.
if (squareRange < 2)
squareRange = 2;
int half = squareRange / 2;
List<Agent> nearbyAgents = new List<Agent>();
/*
If the Position of this tile is 2,3 and we pass a squareRange of 2,
this method would return the units in the coords within parentheses.
[0,0] [1,0] [2,0] [3,0] [4,0]
[0,1] [1,1] [2,1] [3,1] [4,1]
[0,2] (1,2) (2,2) (3,2) [4,2]
[0,3] (1,3) (2,3) (3,3) [4,3]
[0,4] (1,4) (2,4) (3,4) [4,4]
[0,5] [1,5] [2,5] [3,5] [4,5]
*/
for (int x = Position.X - half; x <= Position.X + half; x++)
{
for (int y = Position.Y - half; y <= Position.Y + half; y++)
{
Tile tile = World.GetTile(x, y);
if (tile != null)
nearbyAgents.AddRange(tile.agents);
}
}
return nearbyAgents;
}
public List<Agent> GetNearbyAgents(float worldSquareRange)
{
int tileRange = (int)Math.Ceiling(worldSquareRange / World.TileSize);
return GetNearbyAgents(tileRange);
}
}
Now, you might be thinking:
- Why are we only doing a square check?
- This will return agents outside the range (all agents on tiles within the square range)?
Distance checking is generally a costly operation. We want to avoid it as much as we can. We will indeed get agents outside the range.
The idea is that we use the result as a coarse sample. When we try to get the nearest target, other than checking distance, we probably want to also ensure that the agent:
- is visible, a simple boolean check like
agent.IsVisible
- is an enemy, which could be a simple integer comparison like
agent.Team != this.Team
Only when those checks have passed we check the distance, which is the costly operation we want to avoid.
Generally, always put the least performance-costly conditions first, and exit early. Below are some related concepts, although not focused on performance benefits:
- Fail-fast system, Wikipedia
- Early Return Pattern in C#, Miguel Teheran, 2024
About the above example
If in your game an agent is likely to be alone a lot of the time the above system can make sense, since most of the time zero distance checks will be performed.
If however, agents are more likely to be near one another, it will result in distance checking agents that we would not have to if we checked the tile's distance first to begin with.
Furthermore, if you have blocking objects in your tile-based world, which you most likely do, you will probably want to do a breadth-first search to avoid tiles with blocked paths.
In a game with 1,000 active agents, you'd have 1,000 x 1,000 = 1,000,000 distance checks (O(n²)). With this approach, you've likely reduced that number by a factor of 100 — depending on agent spread.
Frequency of Calculations
As a beginner game developer you learn about the magic of Update()
and how most, if not all of the game logic, is triggered from here in one way or another. In a very simplistic sense,
it's the Main()
of software devs. Although both groups of developers most likely are and should be using some sort of events, too.
Update is, however, easily abused. Most likely, you won't need all your agents to check for targets 60+ times a second (16ms).
Step 1: Limiting frequency
Just limiting the frequency here gives a massive performance boost. For a game with a lot of agents, you can probably get away with a rate of 10 times a second (100ms) without it affecting the experience much. Even without the "Divide and Conquer" technique above, we'd have gone from 60 million checks every second, to 6 million.
Frame | Time Elapsed | Notes |
---|---|---|
1 | 16ms | Usual game logic. Checking distances for all (1000) agents. |
2 | 33ms | Usual game logic. |
3 | 50ms | Usual game logic. |
... | ... | ... |
6 | 100ms | Usual game logic. Checking distances for all (1000) agents. |
Once you start your optimization journey here, you'll realize almost nothing needs to run on every Update()
.
By looking at the profiler, you'll notice each frame runs a lot quicker now, but you probably have distinct rises every 10th frame.
If there's a lot of other stuff going on as well, that could result in FPS-spikes, something we all hate. Luckily, there's more we can do.
Step 2: Spread them out
Usually, there's really no logical need to have to run all those calculations at the same time. So why not spread them out? A bonus here is that it introduces a sort of randomization, since agents don't find their targets at the exact time, but with a maximum time drift of 100ms. So instead of a group of enemies all suddenly begin acting in sync, they stagger their responses slightly. Giving a natural shift in their following animations, and so on.
About Target Acquisition
In many games, it's common for agents to alert each other about threats. This is good for optimization. Since when one agent has found a target, it can use our previous optimization technique to simply loop through friendly, nearby agents, and tell them about the threat.
This way, they won't have to search for targets on their own. Unless they are attacked, or in a smaller radius, to deal with threats targeting themselves. If you go this route, then you probably want to introduce some sort of manual randomized delay to prevent the robotic flock behaviour.
Thus, our goal is for the execution to perform like this:
Frame | Time Elapsed | Notes |
---|---|---|
1 | 16ms | Usual game logic. Checking distances for 100 agents. |
2 | 33ms | Usual game logic. Checking distances for 100 agents. |
3 | 50ms | Usual game logic. Checking distances for 100 agents. |
... | ... | ... |
6 | 100ms | Usual game logic. Checking distances for 100 agents. |
An implementation of this could look something like the following.
We give the agent a way to check if they have the green light to perform their calculations,
then, we use that check in the usual Update()
or equivalent:
public class Agent {
// Returns true if it is this agents turn to perform "heavy" operations.
private bool IsOperationTick()
{
// In this example, agents that have spawned within the "lap" are discarded.
if (!OPT_EXCLUDED.Contains(this))
OPT_COUNTER++;
return !OPT_EXCLUDED.Contains(this) &&
OPT_COUNTER >= OPT_FRAME_COUNTER * OPT_PER_FRAME &&
OPT_COUNTER < OPT_FRAME_COUNTER * OPT_PER_FRAME + OPT_PER_FRAME;
}
public void Update()
{
// We run our performance-killer functionality in here.
if (IsOperationTick())
{
CreateVision();
// Uses GetNearbyAgents() internally to find nearby enemies.
AcquireTarget();
}
Orders.Process();
}
}
You might be wondering about the excluded list. Every time an agent spawns, we add it here. This excludes it on the first pass so it won't affect the counter. We also need some other base functionality to control the counter.
public static class GameManager {
public readonly List<Agent> Agents = new List<Agent>();
public static void Update()
{
// Perform the "normal" update.
foreach (Agent agent in Agents) { agent.Update(); }
// See static code below.
agent.OPT_STEP();
}
}
Back to the Agent class, we also have a few static fields and a function:
public class Agent {
#region Static
private static int OPT_COUNTER;
// Increased every frame, and resets after a "lap", in this case a second.
private static int OPT_FRAME_COUNTER;
// The amount of agents to run heavy calculations each frame in this lap,
// based on the active agents at the start of a lap divided by steps per lap.
private static double OPT_PER_FRAME;
private readonly static List<Agent> OPT_EXCLUDED = new List<Agent>();
public static void OPT_STEP()
{
OPT_COUNTER = 0;
OPT_FRAME_COUNTER++;
if (OPT_FRAME_COUNTER >= StepsPerSecond)
{
OPT_FRAME_COUNTER = 0;
OPT_PER_FRAME = Math.Ceiling(
(double)((double)TotalActiveAgents /
(double)StepsPerSecond));
OPT_EXCLUDED.Clear();
}
}
#endregion
}
Ps. in the example above I have used a #region
to keep the static functionality of the class separate. Another solution I like is to
mark the class as partial and split it up into several files, so you could have Agent.cs
and Agent.Static.cs
.
You can read more about partial classes in C# on the Microsoft website.
Further Reading
If you found these tips applicable to your game and implemented them, I'm confident you've significantly increased your frame rate.
By questioning when calculations actually need to run (reduce frequency), and identifying when the same calculation runs repeatedly (cache the result), you'll certainly find a lot of areas to improve. But again, as optimization generally reduces readability, always begin by using the profiler to figure out if a change is even worth it.
Still, if you're not quite happy with those improvements, here's some other optimization ideas, some not directly related to code, to take into account, many of which deserve their own articles one day.
- Memory management and preventing memory leaks.
- Optimizing large assets such as video, textures and audio. Will also help reduce your final game file size.
- Use texture mip maps.
- Use LOD (Level of Detail) models, a must in games with variable distance to objects and far line of sight.
For Unity, see Simplifying distant meshes with level of detail (LOD). - Improving rendering performance by batching and using texture atlases.
- Configuring shaders properly, or making your own, using only the features you need.
If you're using Unity, there's an official article for getting started with improving performance, and if you are really serious about performance consider diving into the Entity Component System (ECS).
If you only want updates to posts and tools, follow the THUNGSTEN page instead.
Links in the footer below.