SaverLoader: save & load procedural scene trees of arbitrary structure
I'm cross-posting here and I, Voyager Forum so folks can get help in either place.
SaverLoader can save procedural scene trees of arbitrary structure and rebuild them on load. It persists data from procedural and non-procedural objects – but only what you tell it to persist! Saves and loads are very fast because we don't save whole objects.
I'm pulling SaverLoader out of I, Voyager for distribution on the Godot Asset Library, so this tutorial is for users that may or may not be developing an I, Voyager-derived project. However, it might be useful to have a look in our code to see SaverLoader used in context.
I, Voyager has >100 planets & moons and typically runs with ~65,000 asteroids. When starting a new game, the entire Solar System is built "procedurally" from external data files. When a game is saved, the present state of all of that goes in the save file. On load, the Solar System "tree" (with Sun as root) is rebuilt from the save file. Our save/load times with an ssd drive are on the order of ~1 second!
How does SaverLoader know which objects to persist?
The presence of the constant "PERSIST_AS_PROCEDURAL_OBJECT" tells SaverLoader that an object is a "persist object". The value of that constant tells SaverLoader whether:
[true] the object needs to be freed and recreated on load (preserving parent-child structure of nodes), or
[false] the object may have some persist data but it shouldn't be freed.
SaverLoader can persist objects of class Node or Reference (but only Nodes can have PERSIST_AS_PROCEDURAL_OBJECT = false). There are some rules to follow so that SaverLoader can find the object for persistence; these are in More rules! below.
I'm doing Scenes, not Scripts!
SaverLoader sees everything as Scripts, not Scenes. But it can instance scenes if the persist node's script tells it where to find the scene. Either of these two constants in a persisted Node's script would do that:
const SCENE := "res://.../my_scene.tscn"
const SCENE_OVERRIDE := "res://.../my_override_scene.tscn"
The second is useful for a subclass that has a scene different than its parent class.
What can SaverLoader persist?
Within objects that are persisted (defined above), SaverLoader can persist properties that contain built-in types, arrays & dictionaries (of arbitrary nesting structure), other persist objects (as defined above), and weak references to persist objects. I, Voyager uses this mostly for script vars, but also for some simple properties like Node.name. In theory it should work for something like a mesh resource, but I've never tested this.
What should I NOT persist?
The whole point of SaverLoader is to NOT persist what you don't need to persist. A whole lot of your object probably doesn't change or can be rebuilt from a little bit of persisted data. That's the strategy we take in I, Voyager.
How do I tell SaverLoader which properties to persist?
There are some additional constants you add to your persist object to tell SaverLoader what exactly to persist. The constant names can be changed but out-of-the box we have:
const PERSIST_PROPERTIES := 
const PERSIST_OBJ_PROPERTIES := 
The contents of those arrays are the names (strings) of properties you want to persist in the object. If the property holds a "persist object" (either directly or nested in an array or dict) then it needs to be in the second array. Anything in the 1st array could be in the 2nd, but it's faster to keep non-object stuff in the 1st. For example, in I, Voyager we persist orbital data for 10000s - 100000s of asteroids in pool arrays that are named in PERSIST_PROPERTIES.
- Persisted Nodes must be in the tree.
- All ancestor nodes up to root must also be persisted nodes.
- A persisted node with PERSIST_AS_PROCEDURAL_OBJECT = false cannot be child of a node with with PERSIST_AS_PROCEDURAL_OBJECT = true.
- Non-procedural nodes must have stable names (path cannot change).
- Inner classes can't be persist objects
- Virtual method _init() cannot have any args.
- Persisted References must always have PERSIST_AS_PROCEDURAL_OBJECT = true
- A persisted Reference must itself be persisted in a persist Node (that's a mouthful!). In other words, SaverLoader will only find the Reference if it is held in some variable (possibly nested in an array or dict) that is listed in a Node's PERSIST_OBJ_PROPERTIES.
More details can be found in the file header comments.
How do I add this to my game?
SaverLoader doesn't have any GUI. You'll have to have your own save/load dialog popups. SaverLoader does have a member called "progress" you can use for your own progress bar (e.g., as we do here). The simplified code below shows how to use SaverLoader's save_game() and load_game() functions in context. The rest of the game would use the signals here to know when to run, stop, finish threads, etc.
extends Node class_name MyMain signal game_save_started() signal game_save_finished() signal game_load_started() signal game_load_finished() var _saver_loader := SaverLoader.new() func save_game(path: String) -> void: var save_file := File.new() save_file.open(path, File.WRITE) emit_signal("game_save_started") _saver_loader.save_game(save_file, get_tree()) yield(_saver_loader, "finished") emit_signal("game_save_finished") func load_game(path: String) -> void: var save_file := File.new() save_file.open(path, File.READ) emit_signal("game_load_started") _saver_loader.load_game(save_file, get_tree()) yield(_saver_loader, "finished") emit_signal("game_load_finished")