Tutorial 46: Custom Content Readers
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 start — Tutorial 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 theContentManager&they are handed, to load sub-assets recursively. - If several
.cnjdocument types should deserialise into the same C++ type, register named.cnjloaders withRegisterCnjLoaderinstead —RegisterTypeReader<T>allows only one reader perT. - JSON is not mandated — a reader can parse binary, CSV, XML, or any other format.
- Each
ContentManagerinstance maintains its own reader registry. If you use multiple managers (one per screen), register your readers on each.