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 start — Tutorial 04: The Game Class Lifecycle — saving is normally triggered from a lifecycle callback such as OnExiting. Read the web note below before you plan a save feature for an Emscripten build, especially a threaded one.

⚠

On the web, saves live in the browser’s IndexedDB. For Emscripten builds CNA’s storage module mounts IndexedDB-backed IDBFS at /cna-storage (the StorageDevice root) and /save (IsolatedStorage) and restores both before main() runs; later writes are persisted automatically. The exception is a threaded build: it uses WasmFS by default, which cannot host IDBFS, so StorageDevice reports persistent storage as unavailable there unless you configure -DCNA_EMSCRIPTEN_USE_WASMFS=OFF. Everything else on this page applies to every platform.

CNA implements the XNA 4.0 StorageDevice / StorageContainer API for reading and writing save data. The API abstracts over platform-specific locations: on Linux a save lands under ~/.local/share/ (or $XDG_DATA_HOME), on Windows under %LOCALAPPDATA%, on macOS under ~/Library/Application Support/, and on Android in the app’s private files directory. The exact rules, and the folder name, are in Platform save paths.

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 per-user writable area. You obtain a StorageDevice through StorageDevice::BeginShowSelector() and EndShowSelector(); on CNA no selector UI is shown and the pair completes immediately. There is no StorageDevice::GetDefault().

#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. You open one with the Begin/End pair. There is no OpenContainer() shortcut and no getPathProperty(): you never receive a filesystem path, only the container object.

// Open (creating it on first use) a container named "SaveSlot1"
auto pending   = device->BeginOpenContainer("SaveSlot1", nullptr, nullptr);
auto container = device->EndOpenContainer(pending.get());   // std::unique_ptr<StorageContainer>

Everything inside the container is reached through the container object, and file contents are read and written as System::IO::Streams (from the Sharp Runtime that CNA builds on), not with std::ofstream:

MemberWhat it does
CreateFile("save.json")Creates the file, or truncates an existing one, and returns a std::unique_ptr<System::IO::Stream>
OpenFile("save.json", FileMode::Open)Opens a file with a System::IO::FileMode (optionally with FileAccess and FileShare); returns a stream
FileExists / DeleteFileTest for, or remove, one file
GetFileNames() / GetFileNames("*.json")List the files in the container, optionally with a */? pattern
CreateDirectory / DirectoryExists / DeleteDirectory / GetDirectoryNames()The same for sub-folders
Dispose()Releases the container (the destructor does it for you)
ⓘ

Names must stay inside the container. A container name, and every file or directory name you pass to a container, must be a relative path that stays inside it. Absolute paths, .. escapes and symbolic links that lead outside throw std::invalid_argument. StorageDevice::DeleteContainer(name) applies the same rule, so a save slot cannot be used to delete anything else.

Platform save paths

PlatformRoot save path
Linux$XDG_DATA_HOME/<app>/ if that variable is set, otherwise ~/.local/share/<app>/
Windows%LOCALAPPDATA%\<app>\ (Local, not Roaming: it is not %APPDATA%)
macOS~/Library/Application Support/<app>/
Androidthe app-private files directory, then <app>/
No home directory known<app>/ under the current working directory
Web (Emscripten)/cna-storage/<app>/, an IndexedDB-backed mount restored before main(); saves survive a page reload (a threaded build needs -DCNA_EMSCRIPTEN_USE_WASMFS=OFF)

The variables are checked in the order shown (XDG_DATA_HOME, then LOCALAPPDATA, then HOME) on every operating system before the platform default applies. Below the root, a container occupies <root>/<containerName>/AllPlayers/ (or Player1, Player2… if you obtained the device for a specific PlayerIndex).

<app> is the literal string game unless you call StorageDevice::SetAppNameEXT("MyGame") (a CNA extension) once at startup, before any storage access. Nothing derives it from the window title or the executable name, so two CNA games that never call SetAppNameEXT share one folder. StorageDevice::GetStorageRootEXT() returns the resulting absolute root, which is handy for telling players where their saves are. None of this depends on which CNA platform layer (SDL3, headless or terminal) the game runs on, and the directory is created on first use.

StorageDevice::BeginOpenContainer

The XNA async pattern uses IAsyncResult. The Begin* calls return a std::unique_ptr<System::IAsyncResult>; you hand its raw pointer to the matching End* call, which returns its result as a std::unique_ptr too:

// 1. The device: a "selector" that completes immediately on every CNA platform
auto selector = StorageDevice::BeginShowSelector(nullptr, nullptr);   // callback, state
std::unique_ptr<StorageDevice> device = StorageDevice::EndShowSelector(selector.get());

// 2. The container
std::unique_ptr<System::IAsyncResult> pending =
    device->BeginOpenContainer("SaveSlot1", nullptr, nullptr);       // callback, state
std::unique_ptr<StorageContainer> container = device->EndOpenContainer(pending.get());
ⓘ

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. It is also the only form CNA offers: there is no synchronous shortcut, so wrap the two calls in a small helper (as OpenSlot below does).

JSON serialization pattern

CNA does not bundle a JSON library, but a flat save file is easy to write by hand, as below. For production use, bundle a header-only library such as nlohmann/json or rapidjson.

SaveData struct

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

struct SaveData {
    int         level       = 1;
    int         score       = 0;
    int         lives       = 3;
    float       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 parse for the flat object ToJson() writes. Fine for a tutorial;
    // use a real JSON library (nlohmann/json, rapidjson...) for anything richer.
    static SaveData FromJson(const std::string& json) {
        SaveData d;
        d.level       = std::stoi(RawValue(json, "level",       "1"));
        d.score       = std::stoi(RawValue(json, "score",       "0"));
        d.lives       = std::stoi(RawValue(json, "lives",       "3"));
        d.musicVolume = std::stof(RawValue(json, "musicVolume", "0.8"));
        d.sfxEnabled  = RawValue(json, "sfxEnabled", "true") == "true";
        d.playerName  = RawValue(json, "playerName", "");
        return d;
    }

private:
    // Text of the value after "key": up to the next comma / newline / brace,
    // with any surrounding quotes and spaces removed
    static std::string RawValue(const std::string& json, const std::string& key,
                                const std::string& fallback) {
        std::size_t pos = json.find("\"" + key + "\"");
        if (pos == std::string::npos) return fallback;
        pos = json.find(':', pos);
        if (pos == std::string::npos) return fallback;
        std::size_t end = json.find_first_of(",\n}", pos);
        std::string v = json.substr(pos + 1, end - pos - 1);
        const char* trim = " \t\r\"";
        std::size_t first = v.find_first_not_of(trim);
        if (first == std::string::npos) return "";
        return v.substr(first, v.find_last_not_of(trim) - first + 1);
    }
};

The hand-written parser handles exactly the flat object ToJson() produces. A player name containing a comma, brace or quote would break it, which is one more reason to use a real library for real data.

Save function

Saving is: open the slot’s container, create the file, write the text through a StreamWriter, close. Loading is the mirror image with OpenFile and a StreamReader:

// SaveGame.hpp
#pragma once
#include <memory>
#include <stdexcept>
#include <string>
#include "Microsoft/Xna/Framework/Storage/StorageDevice.hpp"
#include "Microsoft/Xna/Framework/Storage/StorageContainer.hpp"
#include "System/IO/FileMode.hpp"
#include "System/IO/StreamReader.hpp"
#include "System/IO/StreamWriter.hpp"
#include "SaveData.hpp"

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

// Open (creating it on first use) the container for one save slot
std::unique_ptr<StorageContainer> OpenSlot(StorageDevice& device, int slot) {
    auto pending = device.BeginOpenContainer("SaveSlot" + std::to_string(slot),
                                             nullptr, nullptr);
    return device.EndOpenContainer(pending.get());
}

bool SaveGame(StorageDevice& device, int slot, const SaveData& data) {
    try {
        auto container = OpenSlot(device, slot);

        // CreateFile() = FileMode::Create: makes the file or overwrites it
        std::unique_ptr<System::IO::Stream> stream = container->CreateFile("save.json");

        System::IO::StreamWriter writer(stream.get());
        writer.Write(data.ToJson());
        writer.Close();          // flushes, then closes the underlying stream
        return true;
    } catch (const std::exception&) {
        return false;            // not connected, name rejected, disk full...
    }
}

bool LoadGame(StorageDevice& device, int slot, SaveData& out) {
    try {
        auto container = OpenSlot(device, slot);
        if (!container->FileExists("save.json")) return false;

        auto stream = container->OpenFile("save.json", System::IO::FileMode::Open);
        System::IO::StreamReader reader(stream.get());
        out = SaveData::FromJson(reader.ReadToEnd());
        return true;
    } catch (const std::exception&) {
        return false;            // includes a corrupt file: stoi/stof throw
    }
}

bool SlotExists(StorageDevice& device, int slot) {
    try {
        return OpenSlot(device, slot)->FileExists("save.json");
    } catch (const std::exception&) {
        return false;
    }
}

StreamWriter and StreamReader wrap the Stream* returned by the container (the unique_ptr keeps ownership); Close() on a writer flushes it and closes the stream.

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 has a save (SlotExists is defined in SaveGame.hpp above)
if (SlotExists(*device_, 3)) { /* show "Continue" */ }

// Delete a slot: removes the whole container folder tree
device_->DeleteContainer("SaveSlot3");

Note that SlotExists opens the container, which creates the (empty) folder on first use. That is harmless, but it means the folder’s existence tells you nothing; test for the file, as the helper does.

Complete game integration

The device and the save-game helpers are created before anything needs them. The base Game::Initialize() is what calls LoadContent() (Tutorial 04), so the device is obtained in the constructor, ahead of the auto-load in LoadContent():

#include <iostream>
#include <memory>
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Microsoft/Xna/Framework/Input/Keys.hpp"
#include "SaveGame.hpp"

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

class SaveDemo final : public Game {
public:
    SaveDemo() : graphics_(this) {
        // CNA extension: name the folder under the per-user data root.
        // Without it the folder is literally called "game".
        StorageDevice::SetAppNameEXT("SaveDemo");

        // Obtain the storage device. Nothing is shown and nothing is asynchronous:
        // the "selector" completes before BeginShowSelector returns.
        auto selector = StorageDevice::BeginShowSelector(nullptr, nullptr);
        device_ = StorageDevice::EndShowSelector(selector.get());
        std::cout << "Saves live in: " << StorageDevice::GetStorageRootEXT() << "\n";
    }

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

    void Update(GameTime& gt) override {
        auto kb = Keyboard::GetState();
        if (kb.IsKeyDown(Keys::F5) && !prevKb_.IsKeyDown(Keys::F5)) {
            save_.score += 100;  // increment for demo
            std::cout << (SaveGame(*device_, 1, save_) ? "Saved" : "Save FAILED") << "\n";
        }
        if (kb.IsKeyDown(Keys::F9) && !prevKb_.IsKeyDown(Keys::F9)) {
            std::cout << (LoadGame(*device_, 1, save_) ? "Loaded" : "Nothing to load")
                      << ", score = " << save_.score << "\n";
        }
        if (kb.IsKeyDown(Keys::Escape)) Exit();
        prevKb_ = kb;
    }

    void Draw(const GameTime&) override {
        getGraphicsDeviceProperty().Clear(Color::DarkGreen);
        spriteBatch_->Begin();
        spriteBatch_->End();
    }

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

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

Press F5 to add 100 to the score and save, F9 to load, Escape to quit. Run it twice: the second run auto-loads the score saved by the first. The console prints the folder the saves went to.

For a standalone, buildable project that does the same with a CMake file, a versioned save format, and a tour of everything that can fail, see Tutorial 141: Save Data Portably with StorageDevice.

Web platform notes

Emscripten builds get a working StorageDevice and StorageContainer backed by the browser’s IndexedDB. CNA’s storage module links Emscripten’s IDBFS, mounts it at /cna-storage (StorageDevice) and /save (IsolatedStorage), and holds main() back until the previously saved files have been restored; after that, closing or deleting a file is flushed to IndexedDB automatically. The serialisation code above therefore works unchanged in the browser, and a save survives a page reload. If the mount or the restore fails, StorageDevice throws StorageDeviceNotConnectedException (“Persistent browser storage is unavailable.”) instead of quietly saving to memory.

Threaded builds (CNA_ENABLE_EMSCRIPTEN_THREADS=ON) use WasmFS by default, and WasmFS cannot host IDBFS, so StorageDevice reports storage as unavailable there. Configure -DCNA_EMSCRIPTEN_USE_WASMFS=OFF to get persistence in a threaded build. CNA’s own documentation still recommends WasmFS for games that load content on worker threads, because the legacy file system can wait on the browser’s main thread during background loads. Browser storage belongs to the page’s origin and the player can clear it, so do not treat it as more durable than a desktop save folder.

ⓘ

Test your own save code. The storage module is one of the smaller and more thinly tested parts of CNA. Its tests cover device and container path handling (name and path containment, DeleteContainer, the not-connected exception’s serialisation), but they do not round-trip data through a container, so do not assume edge cases such as concurrent opens or partial writes have been exercised for you. See also the Storage reference.