Game components and the service container

CNA snapshot 009d40f5  ·  Deep Dives › Framework core  ·  source links pinned to 009d40f5

✓

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 009d40f5; unless a sentence says otherwise, nothing here was built or executed. GameComponent and DrawableGameComponent have no tests, so their setter order, load/unload rules and disposal behaviour are 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, raise the public event, then call the protected OnEnabledChanged / OnUpdateOrderChanged / OnVisibleChanged / OnDrawOrderChanged virtual, whose base body is empty; setting the current value does nothing. In XNA 4.0 the protected On…Changed method is what raises the event, so an XNA override that skips its base suppresses the event; in CNA an override cannot suppress 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 only from Dispose(bool), which then resets the flag, so a component disposed and initialized again loads 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 does not unregister

GameComponent::Dispose() calls Dispose(true), which sets the component's disposed flag and raises its Disposed event (only when disposing is true). It does not remove the component from Game.Components. XNA 4.0's GameComponent.Dispose(true) does exactly that removal before raising Disposed, so an XNA idiom such as "dispose the pause menu to make it go away" leaves the CNA component registered: Update and Draw have no disposed guard, and the component keeps being updated and drawn. Remove it from the collection explicitly (Remove() first, then dispose or destroy). The same applies at game shutdown: Game::Dispose() disposes every component and leaves them all registered (consequences).

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.
  • Duplicates and nulls. Adding a pointer that is already present throws std::invalid_argument ("Cannot Add Same Component Multiple Times"). 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 std::out_of_range, 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 std::logic_error (FNA throws NotSupportedException); 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 and a second registration for the same type both throw std::invalid_argument; 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 std::invalid_argument. 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 25, covering events, bounds, duplicates, range-for iteration and shared ownership. GameComponentTests.cpp and DrawableGameComponentTests.cpp contain no tests, so the setter order, CompareTo, the load/unload rules and disposal without unregistration 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 TARGET headers (SDL_RENDERER and SDL3 configuration definitions, 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