Skip to content
C++
Domain Deep-Dive
Advanced

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.

cpp
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

cpp
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.

cpp
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:

cpp
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 stack

STL container integration

C++ allocators can be passed to STL containers:

cpp
// 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

cpp
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

AllocatorAlloc costFree costUse for
Frame/linearO(1)O(1) batch resetPer-frame temporaries
PoolO(1)O(1)Fixed-size, frequent alloc/free
StackO(1)O(1) LIFOScoped lifetimes
General (new)O(1) avg, high varianceO(1) avgNon-hot paths only
Edit on GitHubUpdated 2026-05-01T00:00:00.000Z