Performance optimisation is one of those areas where theory and practice part company quickly. Textbooks start with algorithmic complexity. Forums drift into micro-benchmarks. In a game, the pressure points are usually data layout, memory access patterns, and knowing when to stop fiddling with code that is already fast enough.
At Relish Games, C++ work and frameworks like HGE mean performance-sensitive code is part of the job, not a side topic. This guide stays with the techniques that matter in 2D game development: profiling, cache behaviour, allocation, batching, compiler settings, and the point where further work stops paying rent.
Start with profiling, not guesses
Never optimise without profiling data. The place that feels slow is often not the place that is slow. Old mistake. Still common.
A sensible profiling workflow
Reproduce the problem in the exact scenario where it shows up. Then profile under realistic conditions, because debug builds, synthetic benchmarks, and release builds do not behave the same way. Identify the hot path - the functions and code paths that consume most of the time - and fix that first. Measure again afterwards so you know whether the change was worth keeping.
Tools that are actually useful
- Visual Studio Profiler: integrated, sampling-based, and a decent starting point
- Intel VTune: detailed hardware counter analysis, with particularly good cache analysis
- Tracy: an open-source frame profiler for games that breaks frames down visually
- Manual timing: simple
QueryPerformanceCounterblocks around suspected hot paths
For game development, frame-time profiling is usually more useful than traditional function-level profiling. Knowing that a function is expensive is fine. Knowing which system is blowing the frame budget is better.
Cache-friendly data layouts
Modern CPUs are quick. Memory is slow by comparison. The cache hierarchy exists to hide some of that gap, but it only helps if the data is arranged and accessed in a way the CPU can follow without thrashing around.
Array of structures and structure of arrays
The natural approach is array of structures (AoS):
struct Entity {
float x, y; // Position
float vx, vy; // Velocity
int health; // HP
int spriteId; // Visual
bool active; // State
};
std::vector<Entity> entities;
The cache-friendly version is structure of arrays (SoA):
struct Entities {
std::vector<float> x, y;
std::vector<float> vx, vy;
std::vector<int> health;
std::vector<int> spriteId;
std::vector<bool> active;
};
The difference shows up in update loops. If the physics system only needs position and velocity, an AoS layout pulls whole Entity structs into cache lines even when part of that data is irrelevant. That wastes bandwidth on health, spriteId, and active. SoA loads only the arrays the loop actually touches, which is why it tends to scale better in hot code.
The trade-off is blunt. SoA is more awkward to work with and adds complexity. Use it where the payoff is clear - physics updates, collision checks, rendering batches, and other loops that touch large numbers of entities. Keep AoS where readability matters more than squeezing every last bit of cache efficiency, especially in low-frequency systems.
Avoiding expensive allocations
Dynamic memory allocation (new, malloc) costs more than plain computation, and game code usually has a predictable shape. You know roughly how many entities, particles, and projectiles are likely to exist at once. That makes allocation strategy one of the easier places to remove waste.
Pool allocators for short-lived objects
A pool allocator pre-allocates a fixed array of objects and hands them out on demand:
template<typename T, size_t N>
class ObjectPool {
T objects[N];
bool used[N] = {};
public:
T* acquire() {
for (size_t i = 0; i < N; i++) {
if (!used[i]) {
used[i] = true;
return &objects[i];
}
}
return nullptr;
}
void release(T* obj) {
size_t idx = obj - objects;
used[idx] = false;
}
};
This is the right fit for objects that are created and destroyed constantly, such as bullets, particles, and temporary effects. It also lines up neatly with how HGE’s particle system manages large numbers of short-lived objects.
Arena allocators for frame-scoped work
An arena allocator hands memory out linearly from a large pre-allocated block. That makes it a good choice for per-frame temporary allocations, where everything can be freed in one go:
class ArenaAllocator {
char* memory;
size_t offset = 0;
size_t capacity;
public:
void* alloc(size_t size) {
void* ptr = memory + offset;
offset += size;
return ptr;
}
void reset() { offset = 0; }
};
Use it for temporary calculations, string building, render command lists - anything allocated during a frame and discarded afterwards.
Hot loops deserve the most attention
The inner loops that run thousands of times per frame are where small savings turn into visible gains.
Cut the work before you cut the cost
Before tweaking how the work is done, check whether the work needs to happen at all. Early-out conditions skip entities that cannot be relevant, such as off-screen objects, inactive systems, or things too far away to matter. Spatial partitioning - grids, quad-trees, or hash maps - limits collision checks to nearby entities instead of comparing everything with everything else. Culling keeps the renderer and simulation away from anything outside the camera view.
Keep branching predictable
Branch mispredictions stall the CPU pipeline. In a tight loop, that stings. Sorting data so branches are predictable helps, for example keeping active entities together and inactive ones elsewhere. Branchless techniques also have a place when the logic is simple, using conditional moves or arithmetic instead of explicit branches. Virtual function calls are another thing to be careful with in hot loops, because the indirect jump is a branch the CPU cannot predict well.
SIMD where the maths fits
Single Instruction, Multiple Data instructions process 4 or 8 values at once. Position updates, distance calculations, and sprite transformations are natural SIMD candidates:
// Scalar: 4 multiplies, 4 adds
for (int i = 0; i < count; i++)
positions[i] += velocities[i] * dt;
// SIMD: processes 4 entities per instruction
__m128 dt_vec = _mm_set1_ps(dt);
for (int i = 0; i < count; i += 4) {
__m128 pos = _mm_load_ps(&positions[i]);
__m128 vel = _mm_load_ps(&velocities[i]);
_mm_store_ps(&positions[i], _mm_add_ps(pos, _mm_mul_ps(vel, dt_vec)));
}
Modern compilers will auto-vectorise simple loops more often than people expect. Check the compiler output first - in Visual Studio, that means /Qvec-report:2 - before writing manual SIMD. Let the compiler have the first attempt. Hand-written SIMD is for the cases where it clearly misses the mark.
Rendering work in 2D games
Batch sprites aggressively
For 2D games, the biggest rendering win is usually batching sprites that share a texture into a single draw call.
Instead of:
Draw sprite 1 (texture A) → 1 draw call
Draw sprite 2 (texture A) → 1 draw call
Draw sprite 3 (texture B) → 1 draw call
Draw sprite 4 (texture A) → 1 draw call
Sort and batch:
Draw sprites 1, 2, 4 (texture A) → 1 draw call
Draw sprite 3 (texture B) → 1 draw call
Going from 4 draw calls to 2 does not sound dramatic. On a busy scene with hundreds of sprites, batching can cut draw calls from 500+ to under 20. That is the sort of reduction that changes how a frame behaves.
The HGE sprite system handles batching internally. If you are building your own engine, getting batching right is high priority. Ignore it and the renderer starts charging interest.
Use texture atlases properly
Texture atlases reduce texture switching by packing sprites together. Basic packing is only the start. Group sprites by rendering order where it makes sense: background atlas, entity atlas, UI atlas. Frequently co-rendered sprites should live in the same atlas where possible. Leave power-of-two padding in place if you need hardware compatibility.
Compiler flags are not optional details
The compiler does more work than many teams give it credit for.
- Release build configuration: obvious, but debug builds can be 10-50x slower
- Link-time optimisation (LTO): enables cross-file inlining and dead code elimination
- Profile-guided optimisation (PGO): compile, profile, and recompile with profile data
- Architecture-specific flags:
/arch:AVX2enables newer SIMD instructions
What this looks like in practice
- Profile first, always. No exceptions.
- Fix the algorithm before fixing the implementation. O(n^2) to O(n log n) beats any micro-optimisation.
- Use SoA for entity systems with more than a hundred entities.
- Pool-allocate anything created and destroyed frequently.
- Batch rendering and use texture atlases from day one.
- Let the compiler auto-vectorise before writing manual SIMD.
- Set a frame budget, then stop when you are within it.
Performance optimisation is satisfying work, but it is still a means to an end. The point is a smooth player experience, not the fastest possible code on a benchmark chart. Once frame times sit comfortably within budget, move on and make the game better.
Explore HGE’s approach to performance in the engine documentation, or discuss optimisation techniques with other developers in our community forum.