Tutorial 46: Custom Content Readers

CNA — C++ XNA 4.0 reimplementation

What you’ll learn

  • Deriving from LooseFileContentTypeReader<T> for your own asset type.
  • Declaring the extensions your reader handles with GetExtensions().
  • Registering the reader so Load<T> finds it.

Before you startTutorial 45: ContentManager and Asset Pipeline — a custom reader plugs into the loader described there.

The ContentManager can be extended to load arbitrary file types by registering a LooseFileContentTypeReader<T>. This lets you route game-specific data (levels, dialogue, item databases) through the same managed asset loading path as textures and models.

The base class is LooseFileContentTypeReader<T>, not ContentTypeReader<T>. It was renamed in July 2026 to free the ContentTypeReader name for the XNB-side reader, which is a different thing with a different signature. If you are following an older article, that is the change that will bite you. This tutorial covers the loose-file path — the one that reads your own JSON, CSV or binary files from disk. Custom types cannot be read from .xnb at all, because CNA has no ReflectiveReader.

ServiceProvider note. ContentManager::ServiceProvider is partially implemented in CNA. The IGraphicsDeviceService service is wired and returned by getServicesProperty(). Third-party service injection (custom services) works at the C++ level but does not mirror the full XNA interface-based service locator.

LooseFileContentTypeReader<T> template base

There are only two members, and only one of them is mandatory:

#include "Microsoft/Xna/Framework/Content/LooseFileContentTypeReader.hpp"

namespace Microsoft::Xna::Framework::Content {

    // Base template you derive from for your type T
    template<typename T>
    class LooseFileContentTypeReader {
    public:
        virtual ~LooseFileContentTypeReader() = default;

        // Extensions this reader handles, e.g. {".level.json"}.
        // When Load() is given a name with no extension, ContentManager
        // tries each of these in turn until a file exists.
        // Return {} if your reader is always handed a full path.
        [[nodiscard]] virtual std::vector<std::string> GetExtensions() const { return {}; }

        // Read the asset. `path` is the full filesystem path that
        // ContentManager assembled; `cm` is there for sub-asset loading.
        virtual T Read(const std::string& path, ContentManager& cm) = 0;
    };

} // namespace

Note what is not here. There is no ContentReader stream object and no existingInstance parameter — those belong to the XNB reader contract. You are handed a path and are expected to open the file yourself, with whatever library suits the format.

Example: LevelData struct and reader

The data type (LevelData.hpp)

// LevelData.hpp
#pragma once
#include <string>
#include <vector>
#include "Microsoft/Xna/Framework/Vector3.hpp"

struct EnemySpawn {
    Microsoft::Xna::Framework::Vector3 position;
    std::string                         type;
    int                                 health;
};

struct LevelData {
    std::string              name;
    std::string              skyboxTexture;
    int                      width  = 0;
    int                      height = 0;
    std::vector<EnemySpawn>  enemies;
};

The descriptor file (Content/levels/level01.level.json)

// Content/levels/level01.level.json
{
    "name":          "Ice Cavern",
    "skyboxTexture": "textures/skybox_ice",
    "width":         64,
    "height":        64,
    "enemies": [
        { "position": [10, 0,  5], "type": "Goblin", "health": 30 },
        { "position": [30, 0, 20], "type": "Troll",  "health": 80 }
    ]
}

The reader (LevelDataReader.hpp)

// LevelDataReader.hpp
#pragma once
#include "Microsoft/Xna/Framework/Content/LooseFileContentTypeReader.hpp"
#include "Microsoft/Xna/Framework/Content/ContentManager.hpp"
#include "LevelData.hpp"
#include <nlohmann/json.hpp>  // or your preferred JSON library
#include <fstream>
#include <string>
#include <vector>

class LevelDataReader
    : public Microsoft::Xna::Framework::Content::LooseFileContentTypeReader<LevelData>
{
public:
    // Lets Load<LevelData>("levels/level01") find level01.level.json
    [[nodiscard]] std::vector<std::string> GetExtensions() const override
    {
        return {".level.json"};
    }

    LevelData Read(
        const std::string& path,
        Microsoft::Xna::Framework::Content::ContentManager& /*cm*/) override
    {
        std::ifstream f(path);
        if (!f.is_open())
            throw std::runtime_error("Cannot open level: " + path);

        nlohmann::json j;
        f >> j;

        LevelData data;
        data.name          = j.at("name").get<std::string>();
        data.skyboxTexture = j.at("skyboxTexture").get<std::string>();
        data.width         = j.at("width").get<int>();
        data.height        = j.at("height").get<int>();

        for (const auto& e : j.at("enemies")) {
            EnemySpawn spawn;
            auto pos          = e.at("position");
            spawn.position    = { pos[0], pos[1], pos[2] };
            spawn.type        = e.at("type").get<std::string>();
            spawn.health      = e.at("health").get<int>();
            data.enemies.push_back(spawn);
        }
        return data;
    }
};

Registering the reader

// In your Game class, before calling Load<LevelData>:
void RegisterCustomReaders() {
    auto& manager = getContentProperty();
    // Register the reader so ContentManager knows how to handle LevelData
    manager.RegisterTypeReader<LevelData>(
        std::make_unique<LevelDataReader>());
}

Loading the custom asset

class MyGame final : public Game {
protected:
    void LoadContent() override {
        auto& content = getContentProperty();
        content.setRootDirectoryProperty("Content");

        // Register before loading
        content.RegisterTypeReader<LevelData>(
            std::make_unique<LevelDataReader>());

        // Load just like any built-in type. Load<T> returns T by value;
        // a failure throws ContentLoadException rather than returning null.
        LevelData level = content.Load<LevelData>("levels/level01");

        for (const auto& enemy : level.enemies) {
            spawnEnemy(enemy.type, enemy.position, enemy.health);
        }
    }

    void spawnEnemy(const std::string& type,
                    const Vector3& pos, int hp) {
        // instantiate enemy based on type string
    }
};

Tips

  • Override GetExtensions() whenever you want callers to pass an extensionless logical name. Return {} only if your reader will always be given a full path.
  • Custom readers can call cm.Load<Texture2D>(subAssetPath) on the ContentManager& they are handed, to load sub-assets recursively.
  • If several .cnj document types should deserialise into the same C++ type, register named .cnj loaders with RegisterCnjLoader instead — RegisterTypeReader<T> allows only one reader per T.
  • JSON is not mandated — a reader can parse binary, CSV, XML, or any other format.
  • Each ContentManager instance maintains its own reader registry. If you use multiple managers (one per screen), register your readers on each.