Tutorial 19: Saving and Loading Data

Storage  ·  StorageDevice  ·  StorageContainer  ·  JSON

What you’ll learn

  • Opening a StorageContainer through StorageDevice::BeginOpenContainer.
  • Where save data actually lands on each platform.
  • Serialising game state to JSON and managing multiple save slots.

Before you startTutorial 04: The Game Class Lifecycle — saving is normally triggered from a lifecycle callback such as OnExiting. Read the web warning below before you plan any save feature for an Emscripten build.

There is no save persistence on the web. Under Emscripten, SDL_GetPrefPath returns a path inside the volatile in-memory MEMFS. CNA mounts no IDBFS and never calls FS.syncfs, so every file a web build writes is silently discarded when the page reloads. Nothing throws and nothing warns — the write appears to succeed. If your game targets the browser and needs to remember anything at all, you have to arrange your own persistence (for example by calling into localStorage or IndexedDB from JavaScript). Everything on this page applies to desktop and Android builds.

CNA implements the XNA 4.0 StorageDevice / StorageContainer API for reading and writing save data. The API abstracts over platform-specific paths, which it derives from SDL_GetPrefPath: on Linux that is under ~/.local/share/, on Windows under %APPDATA%, on macOS under ~/Library/Application Support/, and on Android the app's internal files directory.

StorageDevice overview

StorageDevice represents a physical or virtual storage medium. In XNA on Xbox 360 this was the memory unit or hard drive; in CNA it maps to the current platform's writable area. You obtain a StorageDevice asynchronously via StorageDevice::BeginShowSelector() (desktop platforms auto-select the default device).

#include "Microsoft/Xna/Framework/Storage/StorageDevice.hpp"
#include "Microsoft/Xna/Framework/Storage/StorageContainer.hpp"

using namespace Microsoft::Xna::Framework::Storage;

StorageContainer

A StorageContainer is a named folder within a StorageDevice. Each game typically uses one container per save slot. Open a container with StorageDevice::OpenContainer():

// Open (or create) a container named "SaveSlot1"
auto container = device->OpenContainer("SaveSlot1");

// Get the full filesystem path to write files into
std::string savePath = container->getPathProperty();
// e.g. ~/.local/share/MyGame/SaveSlot1/

Files inside the container are regular files — you can read and write them with standard C++ I/O or any serialisation library.

Platform save paths

PlatformRoot save path
Linux~/.local/share/<GameName>/
Windows%APPDATA%\<GameName>\
Android/data/data/<package>/files/<GameName>/
macOS~/Library/Application Support/<GameName>/
Web (Emscripten)A volatile MEMFS path. Not persistent — discarded on page reload

CNA sets the game name from Game::setTitleProperty() or, if not set, falls back to the executable name. The exact root comes from SDL_GetPrefPath, which also creates the directory on first use.

StorageDevice::BeginOpenContainer

The XNA async pattern uses IAsyncResult. CNA implements this but also provides a synchronous shortcut for simpler use:

// Async-shaped (XNA-compatible):
IAsyncResult* ar = device->BeginOpenContainer("SaveSlot1", nullptr, nullptr);
// ... do other work ...
auto container = device->EndOpenContainer(ar);

// Synchronous shortcut (CNA extension — not in original XNA):
auto container = device->OpenContainer("SaveSlot1");

The Begin*/End* pair is not actually asynchronous. The work happens inline, and any callback you pass runs before Begin* returns — there is no background thread and no "do other work" window to exploit. This is faithful to XNA, whose storage async was also largely fake, but it does mean the async form buys you nothing over the synchronous shortcut except source compatibility with existing XNA code.

JSON serialization pattern

CNA does not bundle a JSON library, but C++23's standard library makes it easy to write lightweight JSON manually. For production use, bundle a header-only library such as nlohmann/json or rapidjson.

SaveData struct

// SaveData.hpp
#pragma once
#include <string>
#include <cstdint>

struct SaveData {
    intcs   level       = 1;
    intcs   score       = 0;
    intcs   lives       = 3;
    Single  musicVolume = 0.8f;
    bool    sfxEnabled  = true;
    std::string playerName;

    // Manual JSON serialise
    std::string ToJson() const {
        return "{\n"
               "  \"level\": "       + std::to_string(level)       + ",\n"
               "  \"score\": "       + std::to_string(score)       + ",\n"
               "  \"lives\": "       + std::to_string(lives)       + ",\n"
               "  \"musicVolume\": " + std::to_string(musicVolume) + ",\n"
               "  \"sfxEnabled\": "  + (sfxEnabled ? "true" : "false") + ",\n"
               "  \"playerName\": \"" + playerName + "\"\n"
               "}\n";
    }

    // Minimal JSON parse (replace with a real library in production)
    static SaveData FromJson(const std::string& json);
};

Save function

#include <fstream>
#include <filesystem>
#include "Microsoft/Xna/Framework/Storage/StorageDevice.hpp"
#include "Microsoft/Xna/Framework/Storage/StorageContainer.hpp"
#include "SaveData.hpp"

bool SaveGame(StorageDevice* device, intcs slot, const SaveData& data) {
    std::string containerName = "SaveSlot" + std::to_string(slot);
    auto container = device->OpenContainer(containerName);
    if (!container) return false;

    std::string path = container->getPathProperty() + "/save.json";
    std::filesystem::create_directories(container->getPathProperty());

    std::ofstream out(path);
    if (!out.is_open()) return false;
    out << data.ToJson();
    return true;
}

bool LoadGame(StorageDevice* device, intcs slot, SaveData& out) {
    std::string containerName = "SaveSlot" + std::to_string(slot);
    auto container = device->OpenContainer(containerName);
    if (!container) return false;

    std::string path = container->getPathProperty() + "/save.json";
    std::ifstream in(path);
    if (!in.is_open()) return false;

    std::string json((std::istreambuf_iterator<char>(in)),
                      std::istreambuf_iterator<char>());
    out = SaveData::FromJson(json);
    return true;
}

Save slots

Multiple save slots are supported by using different container names:

// Save to slot 1
SaveGame(device_, 1, currentSave_);

// Load from slot 2
SaveData slot2;
if (LoadGame(device_, 2, slot2)) {
    // Use slot2 data
}

// Check if a slot exists
bool SlotExists(StorageDevice* device, intcs slot) {
    auto container = device->OpenContainer("SaveSlot" + std::to_string(slot));
    if (!container) return false;
    std::string path = container->getPathProperty() + "/save.json";
    return std::filesystem::exists(path);
}

Complete game integration

#include <memory>
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/Storage/StorageDevice.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Microsoft/Xna/Framework/Input/Keys.hpp"
#include "SaveData.hpp"

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Storage;
using namespace Microsoft::Xna::Framework::Input;

class SaveDemo final : public Game {
public:
    SaveDemo() : graphics_(this) {}

protected:
    void Initialize() override {
        Game::Initialize();
        // Obtain the default StorageDevice (synchronous on desktop)
        device_ = StorageDevice::GetDefault();
    }

    void LoadContent() override {
        spriteBatch_ = std::make_unique<SpriteBatch>(getGraphicsDeviceProperty());
        // Try to auto-load save slot 1 on startup
        if (device_) {
            SaveData loaded;
            if (LoadGame(device_.get(), 1, loaded)) {
                save_ = loaded;
            }
        }
    }

    void Update(GameTime& gt) override {
        auto kb = Keyboard::GetState();

        // F5 = save slot 1
        if (kb.IsKeyDown(Keys::F5) && !prevKb_.IsKeyDown(Keys::F5)) {
            if (device_) {
                save_.score += 100;  // increment for demo
                SaveGame(device_.get(), 1, save_);
            }
        }

        // F9 = load slot 1
        if (kb.IsKeyDown(Keys::F9) && !prevKb_.IsKeyDown(Keys::F9)) {
            if (device_) {
                LoadGame(device_.get(), 1, save_);
            }
        }

        prevKb_ = kb;
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color::DarkGreen);
        spriteBatch_->Begin();
        // Draw save_.score, etc.
        spriteBatch_->End();
        gd.Present();
    }

private:
    GraphicsDeviceManager         graphics_;
    std::unique_ptr<SpriteBatch>  spriteBatch_;
    std::shared_ptr<StorageDevice> device_;
    SaveData                      save_;
    KeyboardState                 prevKb_;
};

int main() { SaveDemo game; game.Run(); }

Web platform notes

Emscripten builds get a working StorageDevice and StorageContainer — files are created, written and read back correctly within a single page session. What they do not get is durability. SDL_GetPrefPath lands in MEMFS, CNA mounts no IDBFS, and nothing ever calls FS.syncfs, so the whole tree evaporates on reload.

If you need saves in the browser, do the persistence yourself. The usual approach is to keep the serialisation code above unchanged, then push the resulting JSON string into localStorage (or IndexedDB, for larger payloads) from an EM_ASM block, and read it back at startup instead of opening a container. Treat the CNA storage path as a scratch area on that platform, not a save location.

Test your own save code. The storage module is one of the more thinly tested parts of CNA — StorageContainer in particular has no tests of its own. The API works, but do not assume edge cases such as unusual container names, concurrent opens or partial writes have been exercised for you.