A game whose frame rate drops in the most hectic moments, or that freezes for an instant every so often, immediately feels low quality. Optimising does not mean rewriting everything in a complicated way from the start, but knowing where time is spent and acting there, methodically.
The frame budget
The starting point is a number. At 60 frames per second each frame has about 16.7 milliseconds available; at 30, about 33.3; at 120, about 8.3. In that time you must read input, update logic, physics and audio, and draw the scene.
Thinking in milliseconds is more useful than thinking in frames per second. Dropping from 60 to 50 fps sounds like a small loss, but it means the frame has gone from 16.7 to 20 ms: 3.3 ms more, a precise amount to hunt down.
Consistency matters too. A game averaging 60 fps but with a 100 ms frame every few seconds is perceived as worse than one that holds steady at 50. These spikes, often called stutter, are just as important as the average.
Measure before optimising
Intuition about where the problem lies is often wrong. The right tool is the profiler, which shows how much time each function or system takes in every frame. All the main engines include one, and if you use a framework you can add manual measurements:
start = precise_time()
update_enemies()
record("enemies", precise_time() - start)
The method:
- reproduce a slow situation repeatably, for example a level with many enemies;
- measure and identify the most expensive entry;
- work on that one only;
- measure again to confirm the improvement.
You also need to understand whether the bottleneck is the CPU or the GPU. If drastically lowering the resolution makes the game much faster, the limit is probably the GPU's per-pixel work; if nothing changes, it is the CPU.
Draw calls and batching
Every time the program asks the graphics card to draw something it issues a draw call, which has a fixed CPU cost regardless of how small the object is. A thousand sprites drawn with a thousand separate calls can be much slower than a thousand sprites drawn in a single call.
Batching groups objects that share a texture and material into a single call. To encourage it:
- gather sprites into shared texture atlases;
- limit the number of different materials and shaders;
- sort drawing by texture when depth order allows it;
- for many identical objects, such as grass or bullets, use instanced drawing.
Object pooling
Constantly creating and destroying objects, for example hundreds of bullets per second, has two costs: initialising each object and, in garbage-collected languages, pauses to reclaim memory. These pauses are a typical cause of stutter.
Object pooling creates a set of objects in advance and reuses them:
pool = [new Bullet() for i from 1 to 200]
function fire():
b = first inactive bullet in pool
if b exists:
b.activate(position, direction)
function on_finished(b):
b.deactivate() // available again
The pool should be sized for the realistic worst case. If it runs out, you can choose to grow it, reuse the oldest object, or simply not spawn the new one.
Other common sources of slowness
| Problem | Typical solution |
|---|---|
| Memory allocations in the game loop | Reuse lists and buffers, avoid string concatenation every frame |
| Repeated lookups of objects by name | Cache references once |
| Collisions between all pairs | Spatial grids or other partitioning structures |
| AI and pathfinding updated every frame | Spread calculations across several frames |
| Logic on off-screen objects | Suspend or simplify distant objects |
| Loading during play | Load in advance or in the background |
| Oversized textures | Sizes suited to their real on-screen use |
Levels of detail
What is far away or small on screen can be simpler. In 3D games, lower-polygon models are used at a distance; more generally, you can reduce the update rate of distant animations, the number of particles or shadow quality. The player does not notice the difference; the processor does.
Optimise with judgement
Premature optimisation can make code complicated without real benefit. A good balance is to write clear code, avoid obvious mistakes such as constant allocations in the main loop, and optimise in a targeted way when measurements call for it. It is also wise to test the game early on the most modest hardware you intend to support, not just on the development machine.
In short
- A frame budget is measured in milliseconds: about 16.7 ms at 60 fps.
- Consistency and the absence of spikes matter as much as the average.
- The profiler shows where to act; intuition alone is often wrong.
- Batching and atlases reduce draw calls; object pooling removes allocations and pauses.
- Levels of detail and spread-out calculations lighten the load with no visible effect.