Last active
January 13, 2022 06:57
-
-
Save xixasdev/c7867d51ee54847bec7053548d39fd99 to your computer and use it in GitHub Desktop.
C++ Gaming: World-state snapshot example for non-interrupting saves
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// SaveStateExample.cpp (C++11) | |
// AUTHOR: xixas | DATE: 2021.12.31 | LICENSE: WTFPL/PDM/CC0... your choice | |
// DESCRIPTION: Gaming: World-state snapshot example for non-interrupting saves. | |
// BUILD: g++ -o save-state-example SaveStateExample.cpp -lpthread -std=c++11 | |
#include <cstdio> // printf | |
#include <atomic> // std::atomic | |
#include <mutex> // std::mutex | |
#include <thread> // std::thread, std::this_thread | |
#include <chrono> // std::chrono | |
std::atomic<bool> isSaving(false); // Thread-safe save state flag | |
// Thread-safe generic type wrapper with snapshot capability. | |
// Temporarily allocates memory for a second value if updated during save. | |
template <typename T> class Saveable { | |
protected: | |
mutable T *a, *b; | |
mutable std::mutex mtx; | |
public: | |
Saveable() { a = b = new T; } | |
Saveable(const T& t) : Saveable() { *a = t; } | |
Saveable(const Saveable<T>& o) : Saveable() { *a = *(o.a); if (o.a != o.b) { b = new T; *b = *(o.b); }} | |
~Saveable() { if (a != b) { delete a; } delete b; } | |
Saveable<T>& operator=(const T& t) { setValue(t); return *this; } // Direct assignment | |
operator T() { return getValue(); } // Direct use as type | |
T operator +() { return getSaveValue(); } // Retrieve save value with a '+' prefix | |
void setValue(const T& t) { | |
std::lock_guard<std::mutex> lock(mtx); | |
if (isSaving) { | |
if (a == b) { b = new T; } | |
} else { | |
if (a != b) { delete a; a = b; } | |
} | |
*b = t; | |
} | |
T getValue() const { | |
std::lock_guard<std::mutex> lock(mtx); | |
if (a != b && !isSaving) { delete a; a = b; } | |
return *b; | |
} | |
T getSaveValue() const { | |
std::lock_guard<std::mutex> lock(mtx); | |
if (a != b && !isSaving) { delete a; a = b; } | |
return *a; | |
} | |
}; | |
typedef Saveable<bool> bool_s; | |
typedef Saveable<int> int_s; | |
typedef Saveable<float> float_s; | |
//-[ Example ]---------------------------------------------------------------- | |
const size_t threadCount = 4, maxSet = 25, saveEvery = 4; | |
std::mutex mtx; | |
bool_s worldBool = false; | |
int_s worldInt = 0; | |
float_s worldFloat = 0.0f; | |
void thread(int id, bool canInitSave, bool canPerformSave) { | |
do { | |
std::this_thread::sleep_for(std::chrono::milliseconds(1)); | |
// Lock and update world values | |
mtx.lock(); | |
if (!isSaving || !canPerformSave) { | |
if (worldInt >= maxSet) { mtx.unlock(); continue; } | |
worldBool = !worldBool; | |
worldInt = worldInt + 1; | |
worldFloat = worldFloat + 0.5f; | |
} | |
const bool b = worldBool, b_ = +worldBool; | |
const int n = worldInt, n_ = +worldInt; | |
const float f = worldFloat, f_ = +worldFloat; | |
bool initSave = canInitSave && !isSaving && n && !(n % saveEvery); | |
int state = !isSaving ? 1 : !canPerformSave ? 2 : (isSaving = false); | |
if (initSave) { isSaving = true; } | |
mtx.unlock(); | |
// Display thread status and world state | |
switch (state) { | |
case 1: printf("Thread %d: Active: [%d, %d, %.1f]\n", id, b, n, f); break; | |
case 2: printf("Thread %d: Active: [%d, %d, %.1f] -> Snapshot: [%d, %d, %.1f]\n", id, b, n, f, b_, n_, f_); break; | |
default: printf("Thread %d: Save Complete... [%d, %d, %.1f]\n", id, b_, n_, f_); break; | |
} | |
if (initSave) { printf("Thread %d: Saving...\n", id); } | |
} while (worldInt < maxSet || (isSaving && canPerformSave)); | |
}; | |
int main() { | |
// Spawn threads | |
std::thread threads[threadCount]; | |
for (size_t i = 0; i < threadCount; ++i) { threads[i] = std::thread(thread, i+1, true, i+1 == threadCount); } | |
for (size_t i = 0; i < threadCount; ++i) { threads[i].join(); } | |
return 0; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Update: See here for a modified example working on large datasets
Quick Save: Storing game state without interrupting the user experience...
Threads and concurrency and the joys of game development, eh?
In simpler (often single-threaded) games, progression can be saved incrementally with little-to-no interruption of the user experience. But as your game grows more complex and larger datasets with significant memory overhead need to be stored, division of concerns and optimal resource management become more important.
There are a variety of solutions to this problem, many based around third-party caching libraries, others as simple as holding duplicate copies of the data at all times. The example herein aims to provide a bare-bones, zero-dependency instant snapshot solution for the purpose of teaching the basics.
Sample Output
What is this? How does it work?
Short version? In simple terms?
It's a generic type wrapper that uses 2 pointers. Sometimes they point to the same value, sometimes they don't.
Longer version?
Pointer/memory allocation is performed transparently by state-based getters/setters:
a
b
a
andb
point to the same memory locationb
is pointed to a new memory location and the modifications are written there insteada
andb
point to different locations, the memory ata
is freed anda
is pointed to the same location asb
, thus resuming normal operationThe advantages of this method are:
This is only 100 lines of code... What's wrong with it?
A lot ;)
Notably:
This is simple sample code and there's no variable registration system. Snapshot data is only cleared on read or write... so if you write to a variable during a save, finish the save, and don't touch that variable again before you begin the next save, it's going to save stale data... that's bad. The solution is to "register" (keep track of) your variables and perform a mass-read across all of them as soon as you complete a save. Don't worry, these are memory pointers in RAM we're talking about -- you can read well over 1_000_000 values in a millisecond.
How To Summary:
update()
method and makeSaveable
extend that classupdate()
method same asgetValue()
(without the return)Note: This is a mutable write! Use
std::lock_guard
, NOTstd::shared_lock
(see point #2 below)vector
or similar collection using the shared typevector
, callingupdate()
on each stored membervector
into a protected static variable or private namespace and register/de-register via the constructor/destructor. Using a protected staticvector
within theSaveable
class would divide the store by template type, but would eliminate the need for theUpdateable
parent class entirely... but I digress. That's outside the necessary scope here.To keep things simple (and buildable in C++11) this example uses single-mutex-based locking, which means, sure, a read and write can't occur at the same time... but neither can two reads! If you're using C++14 or higher, use
std::shared_lock
instead ofstd::lock_guard
in the two getter methods, but only when(a == b || isSaving)
. If you really need this in C++11, Boost provides an implementation, and there are a number of good, tiny, header-only implementations floating around (I'm not holding your hand... go ahead... open up your favorite search engine).Whether you consider this a problem or not depends on your situation, but the example code does not guarantee a save on every 4th value -- and this is intentional. In the real world, if a save is in progress and the user requests to save again, we don't interrupt the current save. We either ignore or queue the second request. Increase the
threadCount
to more readily see what happens when data is updated faster than a save can complete -- spoiler: it skips a save.I used some shortcuts, overloads, and unlabeled abstractions to make this readable in 100 lines -- particularly for state management stuff.
I mean... line 80... really?
The last bit both performs an assignment and auto-casts to zero, thereby falling into the
default:
switch case below.That's terrible. Don't do any of that in shared production code.
This isn't actually saving anything to disk...
Nope. Serialization / data marshalling is a tutorial in and of itself, and would detract from the purpose of this example.
It simply gets delayed by thread scheduling and tells you when it's complete -- same as a real save minus the disk I/O :)
This code formatting is terrible
I know, right?
Fret not, I originally wrote it in typical modified K&R style. It's presented here in a condensed format to get as much on the screen as possible for the sake of learning and immediate visual reference -- think of it like a PowerPoint presentation.
Once you get the concept, the methods are easy to break down. If you don't... see next heading.
I don't understand...
Well then, this wasn't written for you :)
Why don't all developers do this?