Tutorial 141: Save Data Portably with StorageDevice
What you’ll learn: How StorageDevice picks a per-platform save root, why to call SetAppNameEXT, how to save and load a slot through StorageContainer streams, which exceptions to expect, and how a browser build keeps saves in IndexedDB.
Before you start — Tutorial 02 (toolchain and sibling checkouts; sharp-runtime on its apple/m4-stabilization branch) and Tutorial 03 (a CMake project that pulls CNA in with add_subdirectory). Nothing here needs a window or a graphics device. Tutorial 19 shows saving inside a running game; this tutorial is the standalone, portable-by-construction version and uses only the real StorageDevice / StorageContainer API.
Every platform keeps user data in a different place: ~/.local/share on Linux, %LOCALAPPDATA% on Windows, ~/Library/Application Support on macOS, an app-private directory on Android. XNA’s StorageDevice exists so your game never has to know. In this tutorial you write a small program that keeps a save slot — player name, level, score — between runs, on any desktop platform, without a single #ifdef. You will also see exactly where the file lands, how to keep two games from colliding, what the API refuses to do, and what differs in a browser build.
Where saves go
StorageDevice resolves one storage root per process, from the first of these that applies (independent of the windowing platform, and not SDL_GetPrefPath):
| Order | Condition | Root |
|---|---|---|
| 1 | Emscripten (browser) build | /cna-storage/<app>, an IndexedDB-backed mount (see the web) |
| 2 | Android build | the package’s app-private files directory |
| 3 | XDG_DATA_HOME set | $XDG_DATA_HOME/<app> |
| 4 | LOCALAPPDATA set (Windows) | %LOCALAPPDATA%\<app> |
| 5 | HOME set | macOS ~/Library/Application Support/<app>; otherwise ~/.local/share/<app> |
| 6 | nothing set | ./<app> in the working directory |
Below the root the layout is <displayName>/Player{N} or <displayName>/AllPlayers. <app> is the literal string game unless you call StorageDevice::SetAppNameEXT("Name"), so always call it — otherwise every CNA game on the machine shares one folder. The full reference is on the Storage page.
The project
my-cna-workspace/
sharp-runtime/ # git clone -b apple/m4-stabilization https://github.com/libcna/sharp-runtime.git
cna/ # git clone -b apple/m4-stabilization https://github.com/libcna/cna.git
save-demo/
CMakeLists.txt
main.cpp
cmake_minimum_required(VERSION 3.20)
project(SaveDemo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CNA_GRAPHICS_RENDERER "HEADLESS" CACHE STRING "CNA graphics renderer")
set(CNA_BUILD_TESTS OFF CACHE BOOL "")
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../cna ${CMAKE_BINARY_DIR}/cna)
add_executable(save_demo main.cpp)
# Storage is a self-contained module: it needs only Sharp Runtime. A real game links CNA,
# which already includes it.
target_link_libraries(save_demo PRIVATE CNA::Storage)
The code in this tutorial was checked against the CNA headers at snapshot c1c316b9 (the whole program compiles as C++23 against them). The site did not build and run it, so the output shown is what the code paths in StorageDevice.cpp / StorageContainer.cpp produce, not a captured log.
Step 1: name the app, get a device and a container
The Begin/End pairs are XNA’s async pattern; on CNA they complete synchronously — your callback (here nullptr) has already run when Begin* returns. Both Begin* calls return a std::unique_ptr<System::IAsyncResult>, and End* takes .get() of it. There is no StorageDevice::GetDefault(), no synchronous OpenContainer() shortcut and no way to ask a container for its path — the container is the only door.
// main.cpp - Tutorial 141: save and load game data through StorageDevice / StorageContainer
#include <exception>
#include <iostream>
#include <memory>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
#include "Microsoft/Xna/Framework/Storage/StorageDevice.hpp"
#include "Microsoft/Xna/Framework/Storage/StorageDeviceNotConnectedException.hpp"
#include "System/IO/FileMode.hpp"
using namespace Microsoft::Xna::Framework::Storage;
struct SaveGame
{
std::string playerName;
int level = 1;
int score = 0;
};
// ---- 1. A device and a container ------------------------------------------------------------
// Begin/End pairs complete synchronously: the callback has already run when Begin* returns.
std::unique_ptr<StorageDevice> OpenDevice()
{
auto selection = StorageDevice::BeginShowSelector(nullptr, nullptr);
return StorageDevice::EndShowSelector(selection.get());
}
std::unique_ptr<StorageContainer> OpenSlot(StorageDevice& device, const std::string& slot)
{
auto opening = device.BeginOpenContainer(slot, nullptr, nullptr);
return device.EndOpenContainer(opening.get());
}
A container name such as "Slot1" becomes the directory <root>/Slot1/AllPlayers (the selector overload without a PlayerIndex). Select the device with BeginShowSelector(PlayerIndex::One, ...) instead and it becomes <root>/Slot1/Player1: two local players then never collide on one machine.
Step 2: serialise your data
Storage moves bytes, not objects; format is your choice. A one-line version header (cna-save 1) makes an old or corrupt file detectable instead of silently misread, and std::optional makes “no usable save” a normal outcome:
// ---- 2. Serialise to a small text format ----------------------------------------------------
std::string ToText(const SaveGame& save)
{
std::ostringstream out;
out << "cna-save 1\n"
<< "name=" << save.playerName << '\n'
<< "level=" << save.level << '\n'
<< "score=" << save.score << '\n';
return out.str();
}
std::optional<SaveGame> FromText(const std::string& text)
{
std::istringstream in(text);
std::string header;
std::getline(in, header);
if (header != "cna-save 1") return std::nullopt; // wrong or corrupt file
SaveGame save;
std::string line;
while (std::getline(in, line))
{
const auto eq = line.find('=');
if (eq == std::string::npos) continue;
const std::string key = line.substr(0, eq);
const std::string value = line.substr(eq + 1);
try
{
if (key == "name") save.playerName = value;
else if (key == "level") save.level = std::stoi(value);
else if (key == "score") save.score = std::stoi(value);
}
catch (const std::exception&) { return std::nullopt; } // stoi failed
}
return save;
}
Step 3: write and read through the container
CreateFile gives a writable stream that replaces any existing file (it is FileMode::Create); OpenFile(file, FileMode::Open) gives a readable one. Both return std::unique_ptr<System::IO::Stream>, Sharp Runtime’s Stream, whose length is getLengthProperty() and whose Read/Write take a SharpRuntime::bytecs buffer, an offset and a count. FileExists turns “first run” into an ordinary branch:
// ---- 3. Write and read a file through the container -----------------------------------------
void WriteSave(StorageContainer& container, const std::string& file, const SaveGame& save)
{
const std::string text = ToText(save);
const std::vector<SharpRuntime::bytecs> bytes(text.begin(), text.end());
auto stream = container.CreateFile(file); // FileMode::Create: replaces any old file
stream->Write(bytes.data(), 0, static_cast<SharpRuntime::intcs>(bytes.size()));
stream->Flush();
stream->Close();
}
std::optional<SaveGame> ReadSave(StorageContainer& container, const std::string& file)
{
if (!container.FileExists(file)) return std::nullopt; // first run: nothing saved yet
auto stream = container.OpenFile(file, System::IO::FileMode::Open);
std::vector<SharpRuntime::bytecs> bytes(static_cast<std::size_t>(stream->getLengthProperty()));
const auto got = stream->Read(bytes.data(), 0, static_cast<SharpRuntime::intcs>(bytes.size()));
stream->Close();
return FromText(std::string(bytes.begin(), bytes.begin() + got));
}
The write is not atomic. CreateFile truncates the old file first; a crash in the middle of Write can leave a short file. That is why the loader checks the header and returns std::nullopt for anything it cannot parse. If losing a save is unacceptable, write to a second file name and keep the previous slot until the new one has been read back successfully.
Step 4: slots, listing and deleting
The whole program ties the steps together. Naming the app comes first; GetFileNames("*.sav") lists the container’s regular files by wildcard (* and ?; the order is the filesystem’s, so sort if you need one); DeleteContainer("Slot1") removes a slot recursively:
int main(int argc, char** argv)
{
// The app folder is the literal "game" unless you name it - do this before the first use.
StorageDevice::SetAppNameEXT("SaveDemo");
std::cout << "storage root: " << StorageDevice::GetStorageRootEXT() << '\n';
try
{
auto device = OpenDevice();
if (argc > 1 && std::string(argv[1]) == "--reset")
{
device->DeleteContainer("Slot1"); // removes <root>/Slot1 recursively
std::cout << "slot deleted\n";
return 0;
}
auto slot = OpenSlot(*device, "Slot1");
if (auto existing = ReadSave(*slot, "profile.sav"))
{
std::cout << "loaded " << existing->playerName << ", level " << existing->level
<< ", score " << existing->score << '\n';
existing->level += 1;
existing->score += 100;
WriteSave(*slot, "profile.sav", *existing);
std::cout << "saved level " << existing->level << '\n';
}
else
{
std::cout << "no save found - creating one\n";
WriteSave(*slot, "profile.sav", SaveGame{"Ada", 1, 0});
}
for (const std::string& name : slot->GetFileNames("*.sav"))
std::cout << " file: " << name << '\n';
slot->Dispose();
}
catch (const StorageDeviceNotConnectedException& e)
{
std::cerr << "storage unavailable: " << e.what() << '\n'; // root could not be created
return 1;
}
catch (const std::invalid_argument& e)
{
std::cerr << "rejected name or path: " << e.what() << '\n'; // absolute, '..' or empty
return 1;
}
catch (const std::exception& e)
{
std::cerr << "save failed: " << e.what() << '\n'; // any I/O error from the stream
return 1;
}
return 0;
}
Step 5: what can fail
| Exception | When | What to do |
|---|---|---|
StorageDeviceNotConnectedException | The storage root cannot be created on first use, free/total space cannot be read, or (in a browser) persistent storage is unavailable. | Tell the player saving is unavailable; keep playing. |
std::invalid_argument | A container name or path is empty, absolute, climbs out with .., or resolves through a symlink outside the container. Also thrown by End* for a result from the wrong Begin*. | Fix the name. Never build a container name or path from untrusted text without validating it — CNA refuses, but relying on the refusal is the last line of defence. |
Other std::exception | Stream or filesystem errors (disk full, permissions). | Report the failure; do not delete the previous save. |
DeleteFile on a file that does not exist is a silent no-op, and DeleteDirectory removes only empty directories.
Build and run
cd save-demo
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build --target save_demo -j$(nproc)
# Keep the demo's files inside the project by pointing the root at a local folder (Linux/macOS):
XDG_DATA_HOME="$PWD/build/xdg" ./build/save_demo
XDG_DATA_HOME="$PWD/build/xdg" ./build/save_demo
XDG_DATA_HOME="$PWD/build/xdg" ./build/save_demo --reset
Expected output (the root line shows your absolute path):
# run 1
storage root: /home/you/save-demo/build/xdg/SaveDemo
no save found - creating one
file: profile.sav
# run 2
storage root: /home/you/save-demo/build/xdg/SaveDemo
loaded Ada, level 1, score 0
saved level 2
file: profile.sav
# --reset
storage root: /home/you/save-demo/build/xdg/SaveDemo
slot deleted
Open build/xdg/SaveDemo/Slot1/AllPlayers/profile.sav to see the text. Run it once without XDG_DATA_HOME and the same file appears under ~/.local/share/SaveDemo on Linux; on Windows the root is %LOCALAPPDATA%\SaveDemo and on macOS ~/Library/Application Support/SaveDemo. Delete the slot with --reset when you are done.
The web: IndexedDB-backed saves
Under Emscripten this program saves into the browser’s IndexedDB. CNA’s storage module links Emscripten’s IDBFS, mounts it at /cna-storage, restores it before main() and flushes later writes automatically, so the storage root becomes /cna-storage/<app> and a save survives a page reload. If the mount or the restore failed, StorageDevice throws StorageDeviceNotConnectedException (“Persistent browser storage is unavailable.”) rather than saving to memory. The local GamerServices achievement and leaderboard files live under the same root.
One configuration needs care. A threaded build (-DCNA_ENABLE_EMSCRIPTEN_THREADS=ON) uses WasmFS by default, which cannot host IDBFS, so there StorageDevice throws that exception; configure -DCNA_EMSCRIPTEN_USE_WASMFS=OFF to get persistence (CNA’s documentation keeps WasmFS recommended for games that load content on worker threads). The program above already handles the exception, and on the web it is the one to expect. Browser storage belongs to the page’s origin and the player can clear it, so test a reload in each browser you ship to. The web gotchas tutorial lists the other browser boundaries.
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Saves from different games mix in one folder | Neither called SetAppNameEXT, so both use game. | Call SetAppNameEXT("YourGame") at start-up, before opening any container. |
An old save “vanished” after you added SetAppNameEXT | The root moved from .../game to .../YourGame. | Copy the old folder once, or keep the old name. |
std::invalid_argument opening a container | Empty name, an absolute path, or a .. in it. | Use a simple relative name such as Slot1. |
Compile error: no member GetDefault, OpenContainer or getPathProperty | Older examples used APIs that do not exist. | Use the Begin/End pairs shown above; files go through the container’s streams. |
Save works on desktop, StorageDeviceNotConnectedException in the browser | A threaded WasmFS build, or the browser refused IndexedDB. | See the web section. |
| File order differs between machines | GetFileNames returns the filesystem’s order. | Sort the names. |
Next steps
- Storage reference — every member, the persistence-path chain, containment and tests.
- Tutorial 19: Saving and Loading Game Data — the same idea inside a running
Game, with JSON and save slots. - Tutorial 123: Achievements and Leaderboards — local GamerServices persistence beneath the same root.
- Tutorial 140: Type converters — parse and format the values you store.