Custom Smart Pointers to Prevent C++ Memory Cycles

This article explores how game developers design and implement custom intrusive reference-counting smart pointers to eliminate circular memory dependencies and avoid memory leaks. While standard solutions like std::shared_ptr and std::weak_ptr manage lifetimes dynamically, their decoupled control blocks and atomic overhead often degrade game performance. By building an intrusive dual-counter system embedded directly into engine objects, developers maintain strict ownership semantics, eliminate circular references between interconnected engine systems, and retain high cache locality across frame updates.

The Problem: Circular Dependencies in Game Architectures

Game engines heavily rely on bidirectional relationships. Common examples include:

  • Scene Graphs: A parent node owns its child nodes, while child nodes need references back to their parent to compute world-space transformations.
  • Entity-Component Systems (ECS) and UI: An entity or window container owns its components or child widgets, but components frequently need to query their owning entity.

If both directions use strong reference-counting pointers, neither object's reference count will ever drop to zero, creating a circular reference and causing permanent memory leaks.

Limitations of Standard Smart Pointers in Games

Standard library pointers (std::shared_ptr and std::weak_ptr) pose specific challenges in game development:

  1. Cache Inefficiency: Non-intrusive control blocks are allocated separately from the object payload, causing an extra pointer indirection and cache misses.
  2. Thread Overhead: std::shared_ptr enforces atomic operations on reference counters by default, introducing synchronization costs even in single-threaded systems like rendering or gameplay logic.
  3. Fat Pointers: A std::weak_ptr stores two pointers (one to the object, one to the control block), doubling the memory footprint compared to a raw pointer.

Architectural Solution: Intrusive Dual-Counter System

To eliminate circular dependencies without standard pointer overhead, developers implement custom intrusive smart pointers using two counters: a Strong Reference Count and a Weak Reference Count.

  • Strong Pointer (TStrongPtr<T>): Represents shared ownership. Increments the strong counter. When the strong count hits zero, the managed object is destroyed via its destructor.
  • Weak Pointer (TWeakPtr<T>): Represents non-owning observation. Increments the weak counter. A weak pointer does not prevent the object from being destroyed, breaking circular reference loops.
  • Control Block Integration: Counters can be embedded directly within the base object class (RefCountedBase), or managed through a lightweight internal proxy so that memory for the counters outlives the underlying game object until all weak pointers are released.

Core Implementation

1. The Reference Control Block

When handling weak references, the counters must remain valid even after the game object is destroyed. A common pattern separates the counter state into a lightweight counter block that the object owns intrusively.

struct ReferenceController {
    unsigned int strong_count{0};
    unsigned int weak_count{0};

    void add_strong_ref() noexcept {
        ++strong_count;
    }

    bool release_strong_ref() noexcept {
        return --strong_count == 0;
    }

    void add_weak_ref() noexcept {
        ++weak_count;
    }

    bool release_weak_ref() noexcept {
        return --weak_count == 0;
    }
};

2. The Intrusive Base Class

Game entities inherit from a base class that manages the controller lifecycle:

class RefCounted {
public:
    RefCounted() : controller(new ReferenceController()) {
        controller->add_strong_ref(); // Initial creation ref
    }

    virtual ~RefCounted() = default;

    ReferenceController* get_controller() const noexcept {
        return controller;
    }

    void add_ref() noexcept {
        controller->add_strong_ref();
    }

    void release() noexcept {
        if (controller->release_strong_ref()) {
            destroy();
        }
    }

protected:
    virtual void destroy() noexcept {
        ReferenceController* ctrl = controller;
        delete this; // Invokes destructor
        if (ctrl->release_weak_ref()) {
            delete ctrl;
        }
    }

    ReferenceController* controller;
};

3. The Weak Pointer Implementation

The weak pointer references the controller directly without incrementing strong_count, eliminating circular retention.

template <typename T>
class TWeakPtr {
public:
    TWeakPtr() : instance(nullptr), controller(nullptr) {}

    TWeakPtr(T* ptr) : instance(ptr), controller(ptr ? ptr->get_controller() : nullptr) {
        if (controller) {
            controller->add_weak_ref();
        }
    }

    ~TWeakPtr() {
        reset();
    }

    TWeakPtr(const TWeakPtr& other) : instance(other.instance), controller(other.controller) {
        if (controller) {
            controller->add_weak_ref();
        }
    }

    void reset() noexcept {
        if (controller) {
            if (controller->release_weak_ref() && controller->strong_count == 0) {
                delete controller;
            }
            controller = nullptr;
            instance = nullptr;
        }
    }

    bool is_valid() const noexcept {
        return controller && controller->strong_count > 0;
    }

    T* get() const noexcept {
        return is_valid() ? instance : nullptr;
    }

private:
    T* instance;
    ReferenceController* controller;
};

Breaking Cycles in Practice

By pairing strong pointers with weak pointers, engine systems enforce clean ownership hierarchies:

  • Parent to Child: The parent node retains ownership using a strong pointer (TStrongPtr<Node>), keeping the child alive as long as the parent exists.
  • Child to Parent: The child stores a TWeakPtr<Node> pointing back up the tree. Because the weak pointer does not increment the parent's strong count, removing the parent permits its reference count to reach zero.
  • Destruction: When the parent destructs, it releases its strong references to its children. With their strong counts now at zero, the children destruct cleanly. Any dangling weak pointers safely report is_valid() == false because the controller tracks that the active instance was destroyed.

Performance Optimizations for Game Engines

To achieve maximum performance with custom reference counting, developers apply the following engine-level strategies:

  • Policy-Based Threading: Game developers template smart pointers with threading policies (e.g., SingleThreadedPolicy vs. MultiThreadedPolicy). Gameplay code running entirely on the main thread uses non-atomic arithmetic, avoiding CPU bus-locking penalties.
  • Arena Allocators for Controllers: Instead of invoking global malloc or delete for ReferenceController instances, engines use pooled or slab allocators to keep counter blocks clustered together in cache memory.
  • Generational Indices as an Alternative: In high-churn systems like particle management or ECS, developers often replace weak pointers entirely with lightweight entity IDs combined with generational counters, sidestepping pointer-tracking overhead altogether.