Saving and Loading a Game

How to save and load a game using Qt's JSON or CBOR classes.

Many games provide save functionality, so that the player's progress through the game can be saved and loaded at a later time. The process of saving a game generally involves serializing each game object's member variables to a file. Many formats can be used for this purpose, one of which is JSON. With QJsonDocument, you also have the ability to serialize a document in a CBOR format, which is great if you don't want the save file to be easy to read (but see Parsing and displaying CBOR data for how it can be read), or if you need to keep the file size down.

In this example, we'll demonstrate how to save and load a simple game to and from JSON and binary formats.

The Character Class

The Character class represents a non-player character (NPC) in our game, and stores the player's name, level, and class type.

It provides static fromJson() and non-static toJson() functions to serialise itself.

Note: This pattern (fromJson()/toJson()) works because QJsonObjects can be constructed independent of an owning QJsonDocument, and because the data types being (de)serialized here are value types, so can be copied. When serializing to another format — for example XML or QDataStream, which require passing a document-like object — or when the object identity is important (QObject subclasses, for example), other patterns may be more suitable. See the xml/dombookmarks and xml/streambookmarks examples for XML, and the implementation of QListWidgetItem::read() and QListWidgetItem::write() for idiomatic QDataStream serialization. The print() functions in this example are good examples of QTextStream serialization, even though they, of course, lack the deserialization side.

class Character
{
    Q_GADGET

public:
    enum ClassType { Warrior, Mage, Archer };
    Q_ENUM(ClassType)

    Character();
    Character(const QString &name, int level, ClassType classType);

    QString name() const;
    void setName(const QString &name);

    int level() const;
    void setLevel(int level);

    ClassType classType() const;
    void setClassType(ClassType classType);

    static Character fromJson(const QJsonObject &json);
    QJsonObject toJson() const;

    void print(QTextStream &s, int indentation = 0) const;

private:
    QString mName;
    int mLevel = 0;
    ClassType mClassType = Warrior;
};

Of particular interest to us are the fromJson() and toJson() function implementations:

Character Character::fromJson(const QJsonObject &json)
{
    Character result;

    if (const QJsonValue v = json["name"]; v.isString())
        result.mName = v.toString();

    if (const QJsonValue v = json["level"]; v.isDouble())
        result.mLevel = v.toInt();

    if (const QJsonValue v = json["classType"]; v.isDouble())
        result.mClassType = ClassType(v.toInt());

    return result;
}

In the fromJson() function, we construct a local result Character object and assign result's members values from the QJsonObject argument. You can use either QJsonObject::operator[]() or QJsonObject::value() to access values within the JSON object; both are const functions and return QJsonValue::Undefined if the key is invalid. In particular, the is... functions (for example QJsonValue::isString(), QJsonValue::isDouble()) return false for QJsonValue::Undefined, so we can check for existence as well as the correct type in a single lookup.

If a value does not exist in the JSON object, or has the wrong type, we don't write to the corresponding result member, either, thereby preserving any values the default constructor may have set. This means default values are centrally defined in one location (the default constructor) and need not be repeated in serialisation code (DRY).

Observe the use of C++17 if-with-initializer to separate scoping and checking of the variable v. This means we can keep the variable name short, because its scope is limited.

Compare that to the naïve approach using QJsonObject::contains():

if (json.contains("name") && json["name"].isString())
    result.mName = json["name"].toString();

which, beside being less readable, requires a total of three lookups (no, the compiler will not optimize these into one), so is three times slower and repeats "name" three times (violating the DRY principle).

QJsonObject Character::toJson() const
{
    QJsonObject json;
    json["name"] = mName;
    json["level"] = mLevel;
    json["classType"] = mClassType;
    return json;
}

In the toJson() function, we do the reverse of the fromJson() function; assign values from the Character object to a new JSON object we then return. As with accessing values, there are two ways to set values on a QJsonObject: QJsonObject::operator[]() and QJsonObject::insert(). Both will override any existing value at the given key.

The Level Class

class Level
{
public:
    Level() = default;
    explicit Level(const QString &name);

    QString name() const;

    QList<Character> npcs() const;
    void setNpcs(const QList<Character> &npcs);

    static Level fromJson(const QJsonObject &json);
    QJsonObject toJson() const;

    void print(QTextStream &s, int indentation = 0) const;

private:
    QString mName;
    QList<Character> mNpcs;
};

We want the levels in our game to each each have several NPCs, so we keep a QList of Character objects. We also provide the familiar fromJson() and toJson() functions.

Level Level::fromJson(const QJsonObject &json)
{
    Level result;

    if (const QJsonValue v = json["name"]; v.isString())
        result.mName = v.toString();

    if (const QJsonValue v = json["npcs"]; v.isArray()) {
        const QJsonArray npcs = v.toArray();
        result.mNpcs.reserve(npcs.size());
        for (const QJsonValue &npc : npcs)
            result.mNpcs.append(Character::fromJson(npc.toObject()));
    }

    return result;
}

Containers can be written to and read from JSON using QJsonArray. In our case, we construct a QJsonArray from the value associated with the key "npcs". Then, for each QJsonValue element in the array, we call toObject() to get the Character's JSON object. Character::fromJson() can then turn that QJSonObject into a Character object to append to our NPC array.

Note: Associate containers can be written by storing the key in each value object (if it's not already). With this approach, the container is stored as a regular array of objects, but the index of each element is used as the key to construct the container when reading it back in.

QJsonObject Level::toJson() const
{
    QJsonObject json;
    json["name"] = mName;
    QJsonArray npcArray;
    for (const Character &npc : mNpcs)
        npcArray.append(npc.toJson());
    json["npcs"] = npcArray;
    return json;
}

Again, the toJson() function is similar to the fromJson() function, except reversed.

The Game Class

Having established the Character and Level classes, we can move on to the Game class:

class Game
{
public:
    enum SaveFormat { Json, Binary };

    Character player() const;
    QList<Level> levels() const;

    void newGame();
    bool loadGame(SaveFormat saveFormat);
    bool saveGame(SaveFormat saveFormat) const;

    void read(const QJsonObject &json);
    QJsonObject toJson() const;

    void print(QTextStream &s, int indentation = 0) const;

private:
    Character mPlayer;
    QList<Level> mLevels;
};

First of all, we define the SaveFormat enum. This will allow us to specify the format in which the game should be saved: Json or Binary.

Next, we provide accessors for the player and levels. We then expose three functions: newGame(), saveGame() and loadGame().

The read() and toJson() functions are used by saveGame() and loadGame().

Note: Despite Game being a value class, we assume that the author wants a game to have identity, much like your main window would have. We therefore don't use a static fromJson() function, which would create a new object, but a read() function we can call on existing objects. There's a 1:1 correspondence between read() and fromJson(), in that one can be implemented in terms of the other:

void read(const QJsonObject &json) { *this = fromJson(json); }
static Game fromObject(const QJsonObject &json) { Game g; g.read(json); return g; }

We just use what's more convenient for callers of the functions.

void Game::newGame()
{
    mPlayer = Character();
    mPlayer.setName("Hero"_L1);
    mPlayer.setClassType(Character::Archer);
    mPlayer.setLevel(QRandomGenerator::global()->bounded(15, 21));

    mLevels.clear();
    mLevels.reserve(2);

    Level village("Village"_L1);
    QList<Character> villageNpcs;
    villageNpcs.reserve(2);
    villageNpcs.append(Character("Barry the Blacksmith"_L1,
                                 QRandomGenerator::global()->bounded(8, 11), Character::Warrior));
    villageNpcs.append(Character("Terry the Trader"_L1,
                                 QRandomGenerator::global()->bounded(6, 8), Character::Warrior));
    village.setNpcs(villageNpcs);
    mLevels.append(village);

    Level dungeon("Dungeon"_L1);
    QList<Character> dungeonNpcs;
    dungeonNpcs.reserve(3);
    dungeonNpcs.append(Character("Eric the Evil"_L1,
                                 QRandomGenerator::global()->bounded(18, 26), Character::Mage));
    dungeonNpcs.append(Character("Eric's Left Minion"_L1,
                                 QRandomGenerator::global()->bounded(5, 7), Character::Warrior));
    dungeonNpcs.append(Character("Eric's Right Minion"_L1,
                                 QRandomGenerator::global()->bounded(4, 9), Character::Warrior));
    dungeon.setNpcs(dungeonNpcs);
    mLevels.append(dungeon);
}

To setup a new game, we create the player and populate the levels and their NPCs.

void Game::read(const QJsonObject &json)
{
    if (const QJsonValue v = json["player"]; v.isObject())
        mPlayer = Character::fromJson(v.toObject());

    if (const QJsonValue v = json["levels"]; v.isArray()) {
        const QJsonArray levels = v.toArray();
        mLevels.clear();
        mLevels.reserve(levels.size());
        for (const QJsonValue &level : levels)
            mLevels.append(Level::fromJson(level.toObject()));
    }
}

The read() function starts by replacing the player with the one read from JSON. We then clear() the level array so that calling loadGame() on the same Game object twice doesn't result in old levels hanging around.

We then populate the level array by reading each Level from a QJsonArray.

QJsonObject Game::toJson() const
{
    QJsonObject json;
    json["player"] = mPlayer.toJson();

    QJsonArray levels;
    for (const Level &level : mLevels)
        levels.append(level.toJson());
    json["levels"] = levels;
    return json;
}

Writing the game to JSON is similar to writing a level.

bool Game::loadGame(Game::SaveFormat saveFormat)
{
    QFile loadFile(saveFormat == Json ? "save.json"_L1 : "save.dat"_L1);

    if (!loadFile.open(QIODevice::ReadOnly)) {
        qWarning("Couldn't open save file.");
        return false;
    }

    QByteArray saveData = loadFile.readAll();

    QJsonDocument loadDoc(saveFormat == Json
                          ? QJsonDocument::fromJson(saveData)
                          : QJsonDocument(QCborValue::fromCbor(saveData).toMap().toJsonObject()));

    read(loadDoc.object());

    QTextStream(stdout) << "Loaded save for " << loadDoc["player"]["name"].toString()
                        << " using " << (saveFormat != Json ? "CBOR" : "JSON") << "...\n";
    return true;
}

When loading a saved game in loadGame(), the first thing we do is open the save file based on which format it was saved to; "save.json" for JSON, and "save.dat" for CBOR. We print a warning and return false if the file couldn't be opened.

Since QJsonDocument::fromJson() and QCborValue::fromCbor() both take a QByteArray, we can read the entire contents of the save file into one, regardless of the save format.

After constructing the QJsonDocument, we instruct the Game object to read itself and then return true to indicate success.

bool Game::saveGame(Game::SaveFormat saveFormat) const
{
    QFile saveFile(saveFormat == Json ? "save.json"_L1 : "save.dat"_L1);

    if (!saveFile.open(QIODevice::WriteOnly)) {
        qWarning("Couldn't open save file.");
        return false;
    }

    QJsonObject gameObject = toJson();
    saveFile.write(saveFormat == Json ? QJsonDocument(gameObject).toJson()
                                      : QCborValue::fromJsonValue(gameObject).toCbor());

    return true;
}

Not surprisingly, saveGame() looks very much like loadGame(). We determine the file extension based on the format, print a warning and return false if the opening of the file fails. We then write the Game object to a QJsonObject. To save the game in the format that was specified, we convert the JSON object into either a QJsonDocument for a subsequent QJsonDocument::toJson() call, or a QCborValue for QCborValue::toCbor().

Tying It All Together

We are now ready to enter main():

int main(int argc, char *argv[])
{
    QCoreApplication app(argc, argv);

    const QStringList args = QCoreApplication::arguments();
    const bool newGame
            = args.size() <= 1 || QString::compare(args[1], "load"_L1, Qt::CaseInsensitive) != 0;
    const bool json
            = args.size() <= 2 || QString::compare(args[2], "binary"_L1, Qt::CaseInsensitive) != 0;

    Game game;
    if (newGame)
        game.newGame();
    else if (!game.loadGame(json ? Game::Json : Game::Binary))
        return 1;
    // Game is played; changes are made...

Since we're only interested in demonstrating serialization of a game with JSON, our game is not actually playable. Therefore, we only need QCoreApplication and have no event loop. On application start-up we parse the command-line arguments to decide how to start the game. For the first argument the options "new" (default) and "load" are available. When "new" is specified a new game will be generated, and when "load" is specified a previously saved game will be loaded in. For the second argument "json" (default) and "binary" are available as options. This argument will decide which file is saved to and/or loaded from. We then move ahead and assume that the player had a great time and made lots of progress, altering the internal state of our Character, Level and Game objects.

    QTextStream s(stdout);
    s << "Game ended in the following state:\n";
    game.print(s);
    if (!game.saveGame(json ? Game::Json : Game::Binary))
        return 1;

    return 0;
}

When the player has finished, we save their game. For demonstration purposes, we can serialize to either JSON or CBOR. You can examine the contents of the files in the same directory as the executable (or re-run the example, making sure to also specify the "load" option), although the binary save file will contain some garbage characters (which is normal).

That concludes our example. As you can see, serialization with Qt's JSON classes is very simple and convenient. The advantages of using QJsonDocument and friends over QDataStream, for example, is that you not only get human-readable JSON files, but you also have the option to use a binary format if it's required, without rewriting any code.

Example project @ code.qt.io

See also JSON Support in Qt, CBOR Support in Qt, and Data Input Output.

© 2024 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.