AlgoDaily

Lesson 5: Game Server - Persistence

Read at your own pace. Code execution, quizzes, progress tracking, and the AI tutor are available in the interactive view with JavaScript enabled.

Why Persistence Matters

Your server needs memory beyond RAM

In Lesson 4: Game Server - Basics, the server became authoritative: it owns sessions, players, message dispatch, and world state. That authority only matters if the world survives disconnects, crashes, deploys, and restarts.

Persistence is where an MMORPG starts feeling like a real world:

  • A player logs back in with the same character.
  • Inventory changes are not lost.
  • Monster spawn definitions survive server restarts.
  • Admin/editor changes can become durable data.

Today we will add a persistence layer without turning the game tick into a database tutorial. The goal is a clean server-side boundary: the simulation runs in memory; persistence captures safe, intentional snapshots of important state.

Why Persistence Matters

Persistence Boundary

Keep the simulation pure-ish

Your authoritative server should not let database concerns leak into every combat, movement, or chat handler. Think in layers, similar to a Java service/repository split, but adapted for a real-time server.

Network messages
     ↓
Session / Player commands
     ↓
Authoritative world tick
     ↓
Domain state changes
     ↓
Persistence boundary
     ↓
Storage adapter

A useful rule:

Game logic mutates live world objects. Persistence reads/writes snapshots or commands at controlled points.

This prevents a dangerous pattern: every tiny state change performing immediate blocking I/O. Instead, we decide what is durable, when it is saved, and how recovery works.

Persistence Boundary

What To Persist?

You are adding persistence to the authoritative server from Lesson 4. Which category is the best first target for durable storage?

Snapshots And Identity

Save values, not object graphs

A common persistence mistake is trying to dump live C++ objects directly. That couples storage to memory layout, pointers, vtables, allocator behavior, and compiler details.

Instead, define compact persistence models:

  • Stable IDs instead of pointers
  • Plain values instead of engine objects
  • Version fields for future migrations
  • Explicit ownership rules

Example distinction:

Runtime object: Player* target, Socket*, AnimationState, Vec2 velocity
Persistent model: playerId, mapId, position, hp, level, inventoryItemIds

For now, we will use simple file-based examples. In a production MMORPG, you may use PostgreSQL, MySQL, SQLite for tools, or a hybrid. The design idea is the same: persist intentional data contracts, not accidental memory layout.

Snapshots And Identity

Tiny Save Format

A first durable player snapshot

This standalone example writes a tiny player snapshot to disk, then reads it back. It is intentionally boring: stable fields, explicit serialization, visible output.

This is not the final database design for an MMO. It is the habit we want: choose the durable contract deliberately.

```cpp
#include <fstream>
#include <iostream>
#include <string>

struct PlayerSnapshot {
    int version = 1;
    int playerId = 0;
    float x = 0.0f;
    float y = 0.0f;
    int hp = 100;
    int level = 1;
};

bool savePlayer(const std::string& path, const PlayerSnapshot& p) {
    std::ofstream out(path);
    if (!out) return false;

    // Simple line format: version playerId x y hp level
    out << p.version << ' '
        << p.playerId << ' '
        << p.x << ' '
        << p.y << ' '
        << p.hp << ' '
        << p.level << '\n';

    return true;
}

bool loadPlayer(const std::string& path, PlayerSnapshot& p) {
    std::ifstream in(path);
    if (!in) return false;

    in >> p.version >> p.playerId >> p.x >> p.y >> p.hp >> p.level;
    return static_cast<bool>(in);
}

int main() {
    PlayerSnapshot before;
    before.playerId = 42;
    before.x = 128.5f;
    before.y = 64.25f;
    before.hp = 87;
    before.level = 3;

    if (!savePlayer("player_42.save", before)) {
        std::cerr << "Failed to save player\n";
        return 1;
    }

    PlayerSnapshot after;
    if (!loadPlayer("player_42.save", after)) {
        std::cerr << "Failed to load player\n";
        return 1;
    }

    std::cout << "Loaded player " << after.playerId
              << " at (" << after.x << ", " << after.y << ")"
              << " hp=" << after.hp
              << " level=" << after.level
              << " version=" << after.version << '\n';
}
```

Blocking The Tick

True or false: It is usually safe for the authoritative server tick to perform blocking disk or database writes directly inside movement/combat handling.

Save Queue Pattern

Decouple save requests from the tick

A practical first design is a save queue:

  1. The world tick mutates authoritative state.
  2. At safe moments, it creates immutable snapshots.
  3. Snapshots are pushed into a persistence queue.
  4. A save worker writes them to storage.
  5. The server logs success/failure and may retry.
Authoritative Tick ──snapshot──> Save Queue ──write──> Storage
       │                              │
       └──── continues next tick ◀────┘

The queue does not make persistence magically free. It gives you a boundary where latency, retries, batching, and backpressure can be reasoned about. We will go deeper on server performance in Lesson 6, so here we focus on correctness and shape.

Save Queue Pattern

Queued Save Demo

A tiny worker-based persistence boundary

This standalone example simulates the server tick producing player snapshots while a save worker writes them. The important idea is ownership: once a snapshot is queued, the worker owns its copy.

Notice that the tick prints immediately instead of waiting for disk work each time.

```cpp
#include <chrono>
#include <condition_variable>
#include <fstream>
#include <iostream>
#include <mutex>
#include <queue>
#include <string>
#include <thread>

struct PlayerSnapshot {
    int playerId;
    int tick;
    int hp;
};

class SaveQueue {
public:
    void push(PlayerSnapshot snapshot) {
        {
            std::lock_guard<std::mutex> lock(mutex_);
            queue_.push(snapshot);
        }
        cv_.notify_one();
    }

    bool pop(PlayerSnapshot& out) {
        std::unique_lock<std::mutex> lock(mutex_);
        cv_.wait(lock, [&] { return stopped_ || !queue_.empty(); });

        if (queue_.empty()) return false;
        out = queue_.front();
        queue_.pop();
        return true;
    }

    void stop() {
        {
            std::lock_guard<std::mutex> lock(mutex_);
            stopped_ = true;
        }
        cv_.notify_all();
    }

private:
    std::mutex mutex_;
    std::condition_variable cv_;
    std::queue<PlayerSnapshot> queue_;
    bool stopped_ = false;
};

void saveWorker(SaveQueue& saves) {
    PlayerSnapshot snapshot;
    while (saves.pop(snapshot)) {
        std::string path = "player_" + std::to_string(snapshot.playerId) + ".queued.save";
        std::ofstream out(path, std::ios::app);
        out << "tick=" << snapshot.tick << " hp=" << snapshot.hp << '\n';

        std::cout << "[save worker] saved player " << snapshot.playerId
                  << " from tick " << snapshot.tick << '\n';
    }
    std::cout << "[save worker] stopped\n";
}

int main() {
    SaveQueue saves;
    std::thread worker(saveWorker, std::ref(saves));

    for (int tick = 1; tick <= 5; ++tick) {
        PlayerSnapshot snapshot{42, tick, 100 - tick};
        saves.push(snapshot);

        std::cout << "[tick] produced snapshot for tick " << tick << '\n';
        std::this_thread::sleep_for(std::chrono::milliseconds(20));
    }

    saves.stop();
    worker.join();

    std::cout << "Server shutdown completed after flushing save queue\n";
}
```

Name The Concept

A saved representation of game state at a specific moment in time is called a ____.

Crash Safe Writes

Avoid half-written saves

If the server crashes while overwriting a save file, you can corrupt the only copy. A simple file-based safety pattern is:

  1. Write the new data to a temporary file.
  2. Flush and close it.
  3. Replace the old file with the temp file.

Real databases provide stronger transaction tools, but this pattern builds the right instinct: durability is not just writing bytes; it is surviving failure at awkward moments.

```cpp
#include <cstdio>
#include <fstream>
#include <iostream>
#include <string>

bool writeTextFile(const std::string& path, const std::string& text) {
    std::ofstream out(path, std::ios::trunc);
    if (!out) return false;
    out << text;
    return static_cast<bool>(out);
}

bool atomicReplaceText(const std::string& finalPath, const std::string& text) {
    const std::string tempPath = finalPath + ".tmp";

    if (!writeTextFile(tempPath, text)) {
        return false;
    }

    // On many platforms, replacing an existing file needs the old file removed first
    // when using the C stdlib rename. Production code should use platform-aware APIs.
    std::remove(finalPath.c_str());

    if (std::rename(tempPath.c_str(), finalPath.c_str()) != 0) {
        std::remove(tempPath.c_str());
        return false;
    }

    return true;
}

int main() {
    const std::string path = "world_checkpoint.save";

    if (!atomicReplaceText(path, "checkpointVersion=1\nplayersOnline=12\n")) {
        std::cerr << "Failed to replace checkpoint\n";
        return 1;
    }

    std::ifstream in(path);
    std::string line;

    std::cout << "Checkpoint contents:\n";
    while (std::getline(in, line)) {
        std::cout << "  " << line << '\n';
    }
}
```

Persistence Checklist

What we now have

You have added the server-side mental model needed for durable MMORPG state:

  • Persist authoritative data, not client visuals or packet buffers.
  • Save explicit snapshots, not raw C++ object memory.
  • Keep blocking I/O outside the authoritative tick.
  • Use a queue or controlled checkpoint boundary.
  • Include version fields so future formats can evolve.
  • Treat crash safety as part of correctness.

A minimal persistence roadmap:

Player login  -> load player snapshot
During play   -> create snapshots after meaningful changes
Periodic save -> checkpoint dirty players/world data
Shutdown      -> flush save queue
Restart       -> rebuild live world from durable records

Next, Lesson 6: Game Server - Performance Optimization will examine how to keep this server responsive under load without breaking the correctness boundaries we introduced here.

Persistence Checklist

Open interactive view →