Game components and the service container

CNA snapshot 3b43bd86  ·  Deep Dives › Framework core  ·  source links pinned to 3b43bd86

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 3b43bd86; unless a sentence says otherwise, nothing here was built or executed. The component hooks, explicit disposal and drawable unloading are pinned by a lifecycle compatibility suite, while ordering and CompareTo remain source readings; XNA 4.0 comparisons come from a disassembly of its Game assembly.

Components and services are how an XNA game shares work and objects without threading pointers through every constructor. CNA keeps both APIs and their names, but C++ ownership and the lack of runtime reflection change several contracts: when a drawable component loads and unloads, what disposal does to registration, how the collection's events and iterators behave, and how a service is keyed. This page gives the exact rules with the XNA 4.0 comparison and two complete, syntax-checked examples. The user-level introduction is the game loop guide and Tutorial 47; the threading history of the component lists is Case study: Game component lifetime.

The component types

IGameComponent has one pure virtual, Initialize(). IUpdateable and IDrawable each expose an enabled-or-visible getter, an order getter, two changed-event accessors returning System::EventHandler& (the C++ form of an event on an interface) and the Update(GameTime&) or Draw(const GameTime&) method. GameComponent (GameComponent.hpp) implements IGameComponent, IUpdateable, System::IComparable<GameComponent> and System::IDisposable; it knows its owning Game (get only), is Enabled by default and has an UpdateOrder of 0. DrawableGameComponent adds IDrawable: Visible (default true), DrawOrder (default 0), LoadContent() / UnloadContent() and a GraphicsDevice getter that simply forwards to the game — so, unlike XNA's, it works before the component is initialized.

The property setters change the value and then call the protected OnEnabledChanged / OnUpdateOrderChanged / OnVisibleChanged / OnDrawOrderChanged virtual, whose base body raises the public event; setting the current value does nothing. That is XNA 4.0's shape: the protected On…Changed method is what raises the event, so an override that skips its base suppresses it, in CNA as in XNA (HooksDispatchAndCanSuppressAllFourChangeEvents). A component the collection owns is kept alive until the hook returns, even if a handler removes it. Where FNA passes a null event argument, CNA passes EventArgs::Empty, because a reference cannot be null.

Update and draw order

Game keeps its own ordered lists of IUpdateable* and IDrawable*. Each insertion goes before the first entry whose order is strictly greater, so lower values run first and equal values keep insertion order; a change to UpdateOrder or DrawOrder re-sorts that one entry through an event subscription Game installed when it categorized the component. Disabled and invisible entries stay in the lists and are skipped per frame.

GameComponent::CompareTo(other) returns other.UpdateOrder - this.UpdateOrder, the reverse of the usual sign convention. Nothing in Game uses it (the lists compare with <), so the sign never affects the loop, but a caller that sorts components with it gets descending order, and for extreme values (orders near the int limits) the subtraction overflows. The member is not part of XNA 4.0's GameComponent, which implements only IGameComponent, IUpdateable and IDisposable, and it is not marked CNAEXT.

Because the base Game::Draw knows nothing about what the derived Draw drew before or after calling it, a component that must appear on top of the game's own drawing needs both a high DrawOrder (to follow other components) and a derived Draw that calls Game::Draw(gameTime) last.

When a drawable component loads and unloads

DrawableGameComponent.cpp implements a deliberately small contract:

  • Initialize() calls LoadContent() the first time (an initialized_ flag guards it). A derived Initialize() that does not call DrawableGameComponent::Initialize() never loads its content.
  • In the ordinary loop, components are initialized by base Game::Initialize(), after DoInitialize has already applied the manager's preferences to the existing device, so this is a sound first load point. A component added later is initialized by Game's ComponentAdded handler on whichever thread called Add — for a loading-screen thread, its LoadContent() runs there.
  • The component does not follow the device. A private OnDeviceCreated that would reload content exists but is subscribed to nothing, and there is no DeviceDisposing hook. UnloadContent() runs from Dispose(true) on every explicit disposal, before the base disposal raises Disposed and even for a component that was never initialized, as XNA's does; the flag is not reset, so a disposed component initialized again does not load again.
  • Destruction never calls UnloadContent() (why).

XNA 4.0's DrawableGameComponent.Initialize() (read from a disassembly of its assembly) is stricter and richer: it throws InvalidOperationException when no IGraphicsDeviceService is registered, hooks the service's DeviceCreated, DeviceResetting, DeviceReset and DeviceDisposing, loads content only when a device already exists, reloads on every DeviceCreated and unloads on DeviceDisposing; its GraphicsDevice getter throws before initialization. A CNA component therefore works in a game without a GraphicsDeviceManager, but must be treated as a one-time initial load, not a device-lifecycle subscriber.

Disposal unregisters

GameComponent::Dispose() calls Dispose(true), which sets the component's disposed flag, removes it from Game.Components (raising ComponentRemoved, so Game drops it from its update and draw lists) and then raises its Disposed event, as XNA 4.0's GameComponent.Dispose(true) does. Every explicit call repeats the removal attempt and the event; the destructor's Dispose(false) does neither. So the XNA idiom "dispose the pause menu to make it go away" works. A component the collection owns through the std::shared_ptr overloads stays alive until the whole virtual disposal call has returned, even when the removal drops the last reference. At game shutdown Game::Dispose() disposes a snapshot of the original components, keeping each alive even if an earlier callback removes it, and each component leaves the collection as it is disposed (consequences). DisposalRemovesComponentBeforeRepeatedEvents, LastOwnedReferenceSurvivesTheEntireVirtualDisposalCall and GameDisposesAnOwningSnapshotDespiteCallbackRemoval pin these rules.

GameComponentCollection

GameComponentCollection.cpp stores raw IGameComponent* entries and raises ComponentAdded / ComponentRemoved (EventHandler<GameComponentCollectionEventArgs>) around Add, Insert, Remove, RemoveAt and Clear, with IndexOf, Contains, getCountProperty() and operator[] for queries. The details that surprise:

  • Ownership. Add(IGameComponent*) and Insert(index, IGameComponent*) borrow. The CNAEXT overloads taking std::shared_ptr<IGameComponent> make the collection an owner, as XNA's collection holds a strong reference; the reference is taken before the insert (so a ComponentAdded handler that removes the component again finds it) and released only after ComponentRemoved has been raised. A rejected add (a duplicate, or a ComponentAdded handler that throws after the insert) keeps the reference whenever the collection still lists the component, so a listed component is never left without an owner.
  • Duplicates and nulls. Adding a pointer that is already present throws System::ArgumentException with XNA's text ("Cannot add the same game component to a game component collection multiple times."), the type an XNA game catches. A null pointer can be inserted once — it raises no event and Game skips it — and a second null throws the same duplicate error.
  • Indices. operator[], RemoveAt and Insert check bounds and throw System::ArgumentOutOfRangeException naming index, as .NET's Collection<T> does and unlike std::vector. IndexOf returns -1 for an absent item; Remove returns false for one and is [[nodiscard]], so discarding its result draws a compiler warning.
  • Event timing. Remove and RemoveAt erase the entry first and then raise ComponentRemoved, so a handler sees a live component that is no longer in the collection. Clear raises ComponentRemoved for every entry while all of them are still in the collection, then empties it. A destroyed collection raises nothing.
  • Iteration. Because C#'s IEnumerable<IGameComponent> has no direct C++ equivalent, the collection adds CNAEXT size_type, iterator and const_iterator aliases and begin() / end(), so it works with range-based for and standard algorithms. The non-const iterators are plain vector iterators over the raw pointers: writing through one replaces an entry without raising either event or updating Game's lists. Treat them as read-only.
  • Replacement. The private SetItem throws System::NotSupportedException, as XNA and FNA do; it is unreachable through the public surface.

Game subscribes to the two events only when DoInitialize runs; components added before Run() are picked up by that method's categorization loop and initialized by base Game::Initialize(). The component lists behind the collection are protected by a recursive mutex so a loading thread may add components; the collection's own storage is not locked.

A self-contained drawable component

A frame counter is the natural first component. It must count in Draw: under the fixed step Update runs at the target rate even when drawing slows down, and the ElapsedGameTime that Draw receives is the game time covered by all the updates of that tick (worked example), so summing it measures draws per second of game time.

class FpsCounterComponent final : public DrawableGameComponent {
public:
    explicit FpsCounterComponent(Game& game) : DrawableGameComponent(game) {}

    void Draw(const GameTime& gameTime) override
    {
        elapsed_ += gameTime.getElapsedGameTimeProperty();   // TimeSpan has operator+=
        ++frames_;
        if (elapsed_.getTotalSecondsProperty() >= 1.0) {
            fps_ = static_cast<double>(frames_) / elapsed_.getTotalSecondsProperty();
            frames_ = 0;
            elapsed_ = TimeSpan::Zero;
        }
        spriteBatch_->Begin();
        spriteBatch_->DrawString(*font_, "FPS: " + std::to_string(static_cast<int>(fps_)),
                                 Vector2(10.0f, 10.0f), Color::Yellow);
        spriteBatch_->End();
    }

protected:
    void LoadContent() override
    {
        font_.emplace(getGameProperty().getContentProperty().Load<SpriteFont>("fonts/hud"));
        spriteBatch_ = std::make_unique<SpriteBatch>(getGraphicsDeviceProperty());
    }

    void UnloadContent() override
    {
        spriteBatch_.reset();
        font_.reset();
    }

private:
    std::optional<SpriteFont> font_;          // SpriteFont has no default constructor
    std::unique_ptr<SpriteBatch> spriteBatch_;
    TimeSpan elapsed_ = TimeSpan::Zero;
    int frames_ = 0;
    double fps_ = 0.0;
};

class MyGame final : public Game {
public:
    MyGame() : graphics_(this), fpsCounter_(*this)
    {
        fpsCounter_.setDrawOrderProperty(1000);      // after every lower DrawOrder
        getComponentsProperty().Add(&fpsCounter_);   // raw pointer: borrowed, not owned
    }

    ~MyGame() override
    {
        // Unregister while both objects are alive; Remove() returns [[nodiscard]] bool.
        static_cast<void>(getComponentsProperty().Remove(&fpsCounter_));
    }

private:
    GraphicsDeviceManager graphics_;
    FpsCounterComponent fpsCounter_;
};

Three details are deliberate. The component is a plain member, and the derived class's members are destroyed before the Game base and its Components member, so being a member does not make it outlive the collection; the destructor's Remove() runs while both are alive and makes Game drop the component from its lists and unsubscribe its order-change handler. DrawOrder 1000 puts it after other components, and it is drawn on top of the game's own drawing only if the derived Draw calls Game::Draw last. And its UnloadContent() runs only when the game is explicitly disposed (game.Dispose() disposes every IDisposable component); destruction alone releases the members through their own destructors.

GameServiceContainer: type-keyed lookup without reflection

C#'s GameServiceContainer is a dictionary keyed by System.Type. C++ has no runtime reflection to build that from a provider object, so GameServiceContainer.hpp keeps a std::unordered_map<std::type_index, void*> keyed by typeid of the template argument, behind AddService<T>, GetService<T> and RemoveService<T> that delegate to non-template std::type_info overloads.

  • The key is the template argument. AddService<ISaveGameService>(&manager) first converts the pointer to ISaveGameService* — adjusting it correctly even when the interface is not the first base — and stores that. A call without the explicit argument, AddService(&manager), deduces the concrete type as the key, and GetService<ISaveGameService>() then returns null. Always spell the interface.
  • The non-template overload trusts its caller. AddService(typeid(IFoo), ptr) takes any void*. Passing a concrete-object pointer converts it to void* without the base adjustment, so a later GetService<IFoo>() casts the wrong address whenever IFoo is not at offset zero. The container cannot check assignability (FNA's IsAssignableFrom check is omitted, as the source notes).
  • No overwrite. A null provider throws System::ArgumentNullException and a second registration for the same type System::ArgumentException, the types XNA throws; there is no last-write-wins. RemoveService<T>() first, then register the replacement. Removing an absent type is a silent no-op; GetService of an absent type returns null.
  • No ownership. The container stores borrowed pointers and never clears them: a service object must outlive its registration or be removed before it is destroyed. Copy construction and copy assignment are deleted ("services are registered by pointer identity") while moves are defaulted — a container can be relocated but never silently duplicated into a second set of pointers; all four are marked CNAEXT.

GraphicsDeviceManager registers itself under IGraphicsDeviceManager and IGraphicsDeviceService, so GetService<GraphicsDeviceManager>() returns null; a second manager on the same game throws System::ArgumentException. Game caches the looked-up manager and device-service pointers during initialization and never refreshes them, so replacing those registrations after Run() has started has no effect on the loop (runtime module internals).

Registering and consuming a service

struct ISaveGameService {
    virtual void Save(const std::string& slot) = 0;
    virtual ~ISaveGameService() = default;
};

class SaveGameManager final : public ISaveGameService {
public:
    void Save(const std::string& slot) override { lastSlot_ = slot; }
private:
    std::string lastSlot_;
};

class ServiceGame final : public Game {
public:
    ServiceGame() : graphics_(this)
    {
        // Name the interface: AddService(&saves_) would deduce SaveGameManager as the key.
        getServicesProperty().AddService<ISaveGameService>(&saves_);
    }

    ~ServiceGame() override
    {
        getServicesProperty().RemoveService<ISaveGameService>();   // the container owns nothing
    }

protected:
    void Update(GameTime& gameTime) override
    {
        if (auto* saves = getServicesProperty().GetService<ISaveGameService>())
            saves->Save("slot1");
        Game::Update(gameTime);
    }

private:
    GraphicsDeviceManager graphics_;
    SaveGameManager saves_;
};

Any component can reach the same service through getGameProperty().getServicesProperty().GetService<ISaveGameService>() without a constructor parameter. The shape follows the container's own tests.

Evidence and limits

Read at this snapshot from the component, collection and container sources and headers linked above and from Game.cpp (CategorizeComponent, SortUpdateable, SortDrawable, OnComponentAdded, OnComponentRemoved). GameServiceContainerTests.cpp has 13 cases, including correct pointer adjustment for a non-primary-base interface, independent lookups of two interfaces on one object and non-ownership; GameComponentCollectionTests.cpp has 34, covering events, bounds, duplicates, range-for iteration and shared ownership, and ComponentLifecycleCompatibilityTests.cpp has 9 that pin the change hooks, repeated explicit disposal, removal before Disposed, drawable unloading and the ownership kept through callbacks. GameComponentTests.cpp and DrawableGameComponentTests.cpp still contain no tests, so CompareTo, the ordering rules and the one-time load are source readings. None of the tests was executed for this page. XNA 4.0 behaviour is quoted from a disassembly of its Microsoft.Xna.Framework.Game assembly. Both examples were syntax-checked as complete translation units with g++ -std=c++23 -fsyntax-only -Wall -Wextra against the headers of the earlier evidence commit, whose declarations used here are unchanged at 3b43bd86 (a single-renderer and an SDL3 platform configuration definition, Sharp Runtime next @ 41b918c9), without warnings; they were not built or run.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Architecture
Runtime lifecycle
Tests and validation
Test architecture