Custom Allocators for Game Development
Pool allocators, stack allocators, frame allocators, and arena allocators — eliminating malloc overhead and fragmentation in game loops.
TL;DR
new/delete in a game loop causes heap fragmentation, unpredictable latency, and cache misses. Replace with purpose-built allocators: frame allocators for per-frame temporaries, pool allocators for fixed-size objects, and arena allocators for grouped lifetimes.
Why not new/delete?
The general-purpose allocator (malloc/new) must handle arbitrary sizes and arbitrary free order — it pays for generality with fragmentation, metadata overhead, and sometimes lock contention.
In a game loop:
- Allocations happen at predictable times (frame start)
- Objects often have predictable lifetimes (frame, level, object pool)
- Sizes are often known in advance (particles, bullets, events)
Custom allocators exploit these constraints.
Frame (linear) allocator
A frame allocator bumps a pointer forward on each allocation and resets it every frame. Allocation is a single pointer increment — essentially free.
class FrameAllocator {
public:
explicit FrameAllocator(size_t capacity)
: buffer_(new std::byte[capacity])
, capacity_(capacity)
, offset_(0) {}
void* alloc(size_t size, size_t align = alignof(std::max_align_t)) {
// Align the current offset
size_t aligned = (offset_ + align - 1) & ~(align - 1);
if (aligned + size > capacity_) return nullptr; // OOM
offset_ = aligned + size;
return buffer_.get() + aligned;
}
void reset() { offset_ = 0; } // O(1) — no destructors called
size_t used() const { return offset_; }
private:
std::unique_ptr<std::byte[]> buffer_;
size_t capacity_;
size_t offset_;
};
// Usage
FrameAllocator frame_alloc(4 * 1024 * 1024); // 4MB per frame
void gameLoop() {
while (running) {
frame_alloc.reset(); // Wipe all per-frame memory
// Allocate temporary data — O(1), no fragmentation
auto* cmd_buf = static_cast<RenderCommand*>(
frame_alloc.alloc(1000 * sizeof(RenderCommand), alignof(RenderCommand)));
// Use cmd_buf for this frame...
// No need to free — reset() handles it
}
}Double-buffered frame allocator
class DoubleBufferedAllocator {
public:
explicit DoubleBufferedAllocator(size_t capacity)
: buffers_{FrameAllocator(capacity), FrameAllocator(capacity)}
, current_(0) {}
void* alloc(size_t size, size_t align = 8) {
return buffers_[current_].alloc(size, align);
}
void swapBuffers() {
current_ ^= 1;
buffers_[current_].reset();
}
private:
FrameAllocator buffers_[2];
int current_;
};Use double-buffering when some per-frame data must survive into the next frame (e.g., async rendering commands dispatched one frame late).
Pool allocator
A pool allocator manages a fixed-size block of objects. Allocation and deallocation are O(1) and cache-friendly.
template<typename T, size_t N>
class PoolAllocator {
public:
PoolAllocator() {
// Build free list
for (size_t i = 0; i < N - 1; ++i)
blocks_[i].next = &blocks_[i + 1];
blocks_[N - 1].next = nullptr;
free_head_ = &blocks_[0];
}
T* alloc() {
if (!free_head_) return nullptr;
Block* b = free_head_;
free_head_ = b->next;
return reinterpret_cast<T*>(b->storage);
}
void free(T* ptr) {
ptr->~T();
Block* b = reinterpret_cast<Block*>(ptr);
b->next = free_head_;
free_head_ = b;
}
private:
union Block {
alignas(T) std::byte storage[sizeof(T)];
Block* next;
};
Block blocks_[N];
Block* free_head_;
};
// Usage — bullet pool
PoolAllocator<Bullet, 1024> bullet_pool;
Bullet* b = bullet_pool.alloc();
new (b) Bullet{pos, vel}; // placement new
// When bullet dies
bullet_pool.free(b);EnTT pools
EnTT (the ECS library) already uses pool allocators internally for component storage. If you're using EnTT, you get pool allocation for free.
Stack allocator
An arena that supports LIFO deallocation — useful for call-stack-like lifetimes:
class StackAllocator {
public:
explicit StackAllocator(size_t cap) : buffer_(new std::byte[cap]), cap_(cap), top_(0) {}
struct Marker { size_t pos; };
void* alloc(size_t size, size_t align = 8) {
size_t aligned = (top_ + align - 1) & ~(align - 1);
if (aligned + size > cap_) return nullptr;
top_ = aligned + size;
return buffer_.get() + aligned;
}
Marker getMarker() const { return {top_}; }
void freeToMarker(Marker m) { top_ = m.pos; }
private:
std::unique_ptr<std::byte[]> buffer_;
size_t cap_, top_;
};
// Usage
StackAllocator level_alloc(64 * 1024 * 1024); // 64MB for level data
auto marker = level_alloc.getMarker();
// ... load sub-level data ...
level_alloc.freeToMarker(marker); // pop sub-level data off the stackSTL container integration
C++ allocators can be passed to STL containers:
// Polymorphic allocator (C++17) — easiest approach
std::pmr::monotonic_buffer_resource pool{4096};
std::pmr::vector<int> v{&pool};
std::pmr::unordered_map<int, int> m{&pool};
// All allocations come from pool; freed when pool is destroyed
// Use with frame allocator
void renderFrame(FrameAllocator& fa) {
std::pmr::monotonic_buffer_resource frame_res{
fa.alloc(64 * 1024), 64 * 1024
};
std::pmr::vector<DrawCall> draw_calls{&frame_res};
// draw_calls gets memory from frame allocator
}Memory budget tracking
class TrackedAllocator {
FrameAllocator base_;
size_t peak_ = 0;
public:
void* alloc(size_t size, size_t align = 8) {
void* p = base_.alloc(size, align);
peak_ = std::max(peak_, base_.used());
return p;
}
void reset() { base_.reset(); }
size_t peak() const { return peak_; }
};Log peak usage per frame to catch budget overruns before they cause hitches.
Summary
| Allocator | Alloc cost | Free cost | Use for |
|---|---|---|---|
| Frame/linear | O(1) | O(1) batch reset | Per-frame temporaries |
| Pool | O(1) | O(1) | Fixed-size, frequent alloc/free |
| Stack | O(1) | O(1) LIFO | Scoped lifetimes |
General (new) | O(1) avg, high variance | O(1) avg | Non-hot paths only |