Skip to main content

Data Persistence Strategy

Executive Summary

Decision: Hybrid Approach (JSON + Binary + SQLite)
Date: November 2025
Status: ✅ Approved

After comprehensive testing of JSON, Binary Packed, and SQLite storage technologies, we determined the optimal format for each type of game data.

Key Finding: Use the right tool for each job — JSON for configuration (human-readable), Binary for save files (5x smaller, 500x faster), and SQLite for structured data (queryable, relational).


Technology Comparison

FormatBest ForPerformanceReadabilityComplexity
JSONConfiguration, Level Data⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
BinaryGame Saves, Network Packets⭐⭐⭐⭐⭐⭐⭐⭐
SQLiteHighscores, Player Profiles⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

1. JSON - Configuration & Debugging

Performance Metrics

Test Data: 10,000 entities
Write Time: ~320 ms
Read Time: ~510 ms
Total: ~830 ms
File Size: 5.6 MB

Advantages ✅

1. Human Readable

{
"window": {
"width": 1920,
"height": 1080,
"fullscreen": true,
"vsync": true
},
"graphics": {
"quality": "high",
"shadows": true,
"particles": true
},
"audio": {
"master_volume": 0.8,
"music_volume": 0.6,
"sfx_volume": 0.9
}
}

Benefits:

  • ✅ Easy to inspect with any text editor
  • ✅ Debugging is straightforward
  • ✅ Manual editing possible for testing
  • ✅ Git diff-friendly for version control

2. Easy Integration

#include <nlohmann/json.hpp>

// Load config
std::ifstream file("config.json");
nlohmann::json config = nlohmann::json::parse(file);

int width = config["window"]["width"];
bool fullscreen = config["window"]["fullscreen"];

// Save config
config["graphics"]["quality"] = "medium";
std::ofstream out("config.json");
out << config.dump(4); // Pretty-print with 4-space indent

3. Flexible Schema

// Optional fields handled naturally
if (config.contains("experimental")) {
bool enableFeature = config["experimental"]["new_feature"];
}

// Forward compatibility
config["new_option"] = true; // Won't break old code

Disadvantages ❌

1. Performance Overhead

  • 🔴 10-20x slower than binary formats
  • 🔴 Text parsing requires CPU cycles
  • 🔴 Not suitable for real-time operations

2. Large File Size

Same data:
Binary: 1.0 MB
JSON: 5.6 MB (5.6x larger)

3. Limited Precision

// Floating-point precision loss
float original = 3.141592653589793;
json["pi"] = original;
float loaded = json["pi"]; // May lose precision
✅ Game Configuration
- Window settings
- Graphics options
- Key bindings
- Audio settings

✅ Level Data
- Enemy spawn patterns
- Wave definitions
- Level layout
- Collectible positions

✅ Asset Metadata
- Texture paths
- Sound references
- Animation definitions

❌ NOT for:
- Real-time game state
- Network packets
- Frequently saved data

2. Binary Packed - Game Saves & Performance

Performance Metrics

Test Data: 10,000 entities
Write Time: ~1.5 ms
Read Time: ~87 μs
Total: ~1.6 ms
File Size: 1.0 MB

Performance vs JSON:

  • 5-20x faster for writes
  • 100-500x faster for reads
  • 5-6x smaller file sizes

Advantages ✅

1. Extreme Performance

struct SaveGame {
uint32_t version;
uint32_t playerId;
float x, y;
uint32_t score;
uint8_t health;
// ... more fields
};

// Write (microseconds!)
SaveGame save{1, 42, 100.5f, 200.3f, 1000, 75};
std::ofstream file("save.dat", std::ios::binary);
file.write(reinterpret_cast<const char*>(&save), sizeof(save));

// Read (microseconds!)
SaveGame loaded;
std::ifstream in("save.dat", std::ios::binary);
in.read(reinterpret_cast<char*>(&loaded), sizeof(loaded));

2. Compact File Size

10,000 entities:
Binary: 1.0 MB ✅
JSON: 5.6 MB (5.6x larger)

3. Direct Memory Mapping

// Can even memory-map large files
#include <sys/mman.h>

int fd = open("entities.bin", O_RDONLY);
SaveData* data = (SaveData*)mmap(nullptr, fileSize,
PROT_READ, MAP_PRIVATE, fd, 0);
// Direct access, zero parsing!

Disadvantages ❌

1. Not Human Readable

$ hexdump save.dat
0000000 0001 0000 002a 0000 0000 42c8 0000 4348
0000010 03e8 0000 004b 0000 ...

2. Endianness Issues

// Must handle big-endian vs little-endian
uint32_t value = 0x12345678;

// Little-endian: 78 56 34 12
// Big-endian: 12 34 56 78

// Solution: Force network byte order
uint32_t netValue = htonl(value);
file.write(&netValue, 4);

3. Versioning Challenges

// Adding a field breaks old saves!
struct SaveGameV1 {
uint32_t version;
uint32_t playerId;
float x, y;
};

struct SaveGameV2 {
uint32_t version;
uint32_t playerId;
float x, y;
uint32_t score; // NEW FIELD!
};

// Need explicit version handling
if (version == 1) {
// Load old format, migrate data
} else if (version == 2) {
// Load new format
}
✅ Game Save Files
- Player progress
- World state
- Inventory data

✅ Network Packets (as shown earlier)
- Entity updates
- Player inputs
- Real-time data

✅ Cache Files
- Compiled assets
- Precomputed data

❌ NOT for:
- Configuration (use JSON)
- Debugging (not readable)

3. SQLite - Structured Data

Performance Metrics

Test Data: 10,000 records
Insert: ~450 ms (with transaction)
Query: ~15 ms (indexed)
File: ~2.5 MB

Advantages ✅

1. SQL Queries

#include <sqlite3.h>

sqlite3* db;
sqlite3_open("highscores.db", &db);

// Create table
const char* createTable = R"(
CREATE TABLE IF NOT EXISTS highscores (
player_name TEXT,
score INTEGER,
level_reached INTEGER,
timestamp INTEGER
);
CREATE INDEX idx_score ON highscores(score DESC);
)";
sqlite3_exec(db, createTable, nullptr, nullptr, nullptr);

// Insert
const char* insert = "INSERT INTO highscores VALUES (?, ?, ?, ?)";
sqlite3_stmt* stmt;
sqlite3_prepare_v2(db, insert, -1, &stmt, nullptr);
sqlite3_bind_text(stmt, 1, "Player1", -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 2, 10000);
sqlite3_bind_int(stmt, 3, 5);
sqlite3_bind_int64(stmt, 4, time(nullptr));
sqlite3_step(stmt);
sqlite3_finalize(stmt);

// Query top 10
const char* query = "SELECT * FROM highscores ORDER BY score DESC LIMIT 10";
sqlite3_prepare_v2(db, query, -1, &stmt, nullptr);
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char* name = (const char*)sqlite3_column_text(stmt, 0);
int score = sqlite3_column_int(stmt, 1);
std::cout << name << ": " << score << "\n";
}

2. ACID Transactions

// Atomic updates
sqlite3_exec(db, "BEGIN TRANSACTION", nullptr, nullptr, nullptr);
// ... multiple inserts/updates
sqlite3_exec(db, "COMMIT", nullptr, nullptr, nullptr);
// All or nothing!

3. Complex Queries

-- Player statistics
SELECT
player_name,
COUNT(*) as games_played,
AVG(score) as avg_score,
MAX(score) as best_score,
MAX(level_reached) as max_level
FROM highscores
WHERE timestamp > strftime('%s', 'now', '-7 days')
GROUP BY player_name
ORDER BY avg_score DESC;

Disadvantages ❌

1. Slower than Binary

10,000 inserts:
Binary: ~1.5 ms ✅
SQLite: ~450 ms (300x slower)

2. Larger Files

10,000 records:
Binary: 1.0 MB
SQLite: 2.5 MB (with indexes)

3. Overkill for Simple Data

// Simple key-value? Use JSON/Binary instead
{
"player_name": "John",
"high_score": 1000
}

// vs full database table
✅ Highscore Tables
- Leaderboards
- Player rankings
- Statistics

✅ Player Profiles
- Account data
- Achievements
- Preferences

✅ Persistent Game State
- Multiplayer lobbies
- Match history
- Session data

❌ NOT for:
- Real-time game state
- High-frequency updates
- Simple key-value data

Use Case Matrix

Data TypeFormatWhy
Game ConfigurationJSONHuman-readable, easy to edit
Level DefinitionsJSONDesigners can modify, version control
Player Save FilesBinaryFast load times, compact size
Network PacketsBinaryMinimal bandwidth, maximum speed
HighscoresSQLiteComplex queries, leaderboards
Player AccountsSQLiteRelational data, authentication
Debug LogsJSONHuman-readable troubleshooting

Implementation Example

class GameDataManager {
// JSON for config
nlohmann::json config_;

// Binary for saves
struct SaveData { /* ... */ };

// SQLite for scores
sqlite3* scoreDB_;

public:
// Load config (JSON)
void loadConfig(const std::string& path) {
std::ifstream file(path);
config_ = nlohmann::json::parse(file);
}

// Save game (Binary)
void saveGame(const SaveData& data) {
std::ofstream file("save.dat", std::ios::binary);
file.write(reinterpret_cast<const char*>(&data), sizeof(data));
}

// Load game (Binary)
SaveData loadGame() {
SaveData data;
std::ifstream file("save.dat", std::ios::binary);
file.read(reinterpret_cast<char*>(&data), sizeof(data));
return data;
}

// Submit score (SQLite)
void submitScore(const std::string& name, int score) {
const char* sql = "INSERT INTO highscores (player_name, score) VALUES (?, ?)";
sqlite3_stmt* stmt;
sqlite3_prepare_v2(scoreDB_, sql, -1, &stmt, nullptr);
sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 2, score);
sqlite3_step(stmt);
sqlite3_finalize(stmt);
}
};

Performance Visualization

Read Performance

10,000 entities:

Binary: ████ 87 μs ✅
SQLite: ████████████████████ 15 ms
JSON: █████████████████████████████████ 510 ms ❌

Write Performance

10,000 entities:

Binary: ████ 1.5 ms ✅
JSON: █████████████████████ 320 ms
SQLite: █████████████████████████ 450 ms ❌

File Size

10,000 entities:

Binary: ██ 1.0 MB ✅
SQLite: █████ 2.5 MB
JSON: ███████████ 5.6 MB ❌

Final Recommendation

Use Hybrid Strategy for R-Type data persistence.

Mapping:

Game SystemStorage FormatRationale
SettingsJSONEasy editing, version control
LevelsJSONDesigner-friendly, readable
SavesBinaryFast loading (87μs), compact (1 MB)
NetworkBinaryMinimal bandwidth, maximum speed
HighscoresSQLiteQueryable leaderboards
ProfilesSQLiteRelational player data
Debug LogsJSONHuman-readable troubleshooting

Benefits:

  • Right tool for each job
  • Maximum performance where needed
  • Human-friendly where useful
  • Structured queries where required

References

  • PoC implementations: /PoC/PoC_DataStorage/
  • Strategy document: /PoC/PoC_DataStorage/data_persistence_strategy.md
  • Binary PoC: /PoC/PoC_DataStorage/BINARY/
  • JSON PoC: /PoC/PoC_DataStorage/JSON/
  • SQLite PoC: /PoC/PoC_DataStorage/SQLite/