umut-sahin / bevy-persistent

A Bevy helper to easily manage resources that need to persist across game sessions.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

bevy-persistent

A Bevy helper to easily manage resources that need to persist across game sessions.

Background

In games, there are a lot of resources that need to persist across game sessions:

  • Statistics (e.g., high scores, number of deaths, play time)
  • Settings (e.g., key bindings, game difficulty, audio settings)
  • States (e.g., last window position and size, saves)
  • and many more...

This crate aims to simplify management of such resources!

Installation

With all supported storage formats:

cargo add bevy-persistent --features all

Or explicitly:

cargo add bevy-persistent --features bincode,ini,json,toml,yaml

And of course, you can just pick the storage formats you're planning to use:

cargo add bevy-persistent --features bincode,toml

Usage

Prelude

You only need Persistent<R> and StorageFormat types to use the library, and they are exported from the prelude module.

use bevy_persistent::prelude::*;

Definition

You need to define the Resource you want to persist, and it needs to implement Serialize and Deserialize traits from serde.

#[derive(Resource, Serialize, Deserialize)]
struct KeyBindings {
    jump: KeyCode,
    crouch: KeyCode,
}

Creation

In your setup system, you can create the persistent resource and insert it to your game.

fn setup(mut commands: Commands) {
    let config_dir = dirs::config_dir().unwrap().join("your-amazing-game");
    commands.insert_resource(
        Persistent::<KeyBindings>::builder()
            .name("key bindings")
            .format(StorageFormat::Toml)
            .path(config_dir.join("key-bindings.toml"))
            .default(KeyBindings { jump: KeyCode::Space, crouch: KeyCode::C })
            .build()
            .expect("failed to initialize key bindings")
    )
}

If it's the first run, the resource will have the specified default value and that default value will be saved to the specified path in the specified format. Otherwise, key bindings will be loaded from the specified path using the specified format.

Access

To access the resource, you can have a parameter of type Res<Persistent<R>>.

fn access_key_bindings(key_bindings: Res<Persistent<KeyBindings>>) {
    log::info!("you can crouch using {:?}", key_bindings.crouch);
}

Persistent<R> implements Deref<Target = R> so you can access public fields/methods of your resource easily.

Modification

To modify the resource, you can have a parameter of type ResMut<Persistent<R>>.

fn modify_key_bindings(mut key_bindings: ResMut<Persistent<KeyBindings>>) {
    key_bindings.update(|key_bindings| {
        key_bindings.crouch = KeyCode::ControlLeft;
    }).expect("failed to update key bindings");
}

Persistent<R> has set and update methods to modify the underlying resource. Both of those methods write the updated resource to the disk before returning.

If any failures happen at any point (e.g., no permission to read/write to the specified path), the error will be returned, but the underlying object would be updated, and new value would be visible for the rest of the game. However, it won't persist to the next game session!

Manual Persistence

Some resources are updated frequently and persisting on each small update might not be desirable. Or persistence could have to be triggered manually (e.g., auto saves on certain points in the game).

For such cases, you can avoid using set and update methods and update the resource directly.

fn modify_key_bindings(mut key_bindings: ResMut<Persistent<KeyBindings>>) {
    key_bindings.crouch = KeyCode::ControlLeft;
}

When you want the resource to persist with its current value, you can use persist method.

fn persist_key_bindings(key_bindings: Res<Persistent<KeyBindings>>) {
    key_bindings.persist().expect("failed to save new key bindings");
}

Reverting

It might make sense for some persistent resources to be reverted to default. Imagine having a key bindings settings page, it's a good idea to put Revert to default button to this page because if players mess up their settings, it'd be much easier if they can revert everything to its default state compared to manually adjusting every key.

Such persistent resources need to be created revertibly.

fn setup(mut commands: Commands) {
    let config_dir = dirs::config_dir().unwrap().join("your-amazing-game");
    commands.insert_resource(
        Persistent::<KeyBindings>::builder()
            .name("key bindings")
            .format(StorageFormat::Toml)
            .path(config_dir.join("key-bindings.toml"))
            .default(KeyBindings { jump: KeyCode::Space, crouch: KeyCode::C })
            .revertible(true)
            //^^^^^^^^^^^^^^^ using this
            .build()
            .expect("failed to initialize key bindings")
    )
}

Revertible persistent resources can use revert_to_default method.

fn revert_key_bindings_to_default(key_bindings: Res<Persistent<KeyBindings>>) {
    key_bindings.revert_to_default().expect("failed to revert key bindings to default");
}

There is also an option to revert to default automatically on deserialization errors (e.g., bad modifications by the player). When enabled, if the persistent object cannot be loaded from the persistent storage due to its content, it'll be reverted to its default.

fn setup(mut commands: Commands) {
    let config_dir = dirs::config_dir().unwrap().join("your-amazing-game");
    commands.insert_resource(
        Persistent::<KeyBindings>::builder()
            .name("key bindings")
            .format(StorageFormat::Toml)
            .path(config_dir.join("key-bindings.toml"))
            .default(KeyBindings { jump: KeyCode::Space, crouch: KeyCode::C })
            .revertible(true)
            //^^^^^^^^^^^^^^^ requires this
            .revert_to_default_on_deserialization_errors(true)
            //^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ enable with this
            .build()
            .expect("failed to initialize key bindings")
    )
}

If key-bindings.toml cannot be deserialized as a KeyBindings object, it'll be reverted to the specified default value in persistent storage.

Unloading/Reloading

Persistent resources are kept in memory by default. This might lead to unnecessarily high memory usage. To overcome this, you can use unload method.

fn unload_key_bindings(key_bindings: Res<Persistent<KeyBindings>>) {
    key_bindings.unload().expect("failed to persist key bindings before unloading");
}

Once this system is run, latest version of the underlying resource will be written to the underlying storage, and underlying resource will be removed from the memory.

If you don't want to persist the latest version of the resource before unloading, or if you know the latest version is already persisted, can use unload_without_persisting method.

fn unload_key_bindings_without_persisting(key_bindings: Res<Persistent<KeyBindings>>) {
    key_bindings.unload_without_persisting();
}

If immediate loading to memory upon creation is not desired, the persistent resource can be created in the unloaded state.

fn setup(mut commands: Commands) {
    let config_dir = dirs::config_dir().unwrap().join("your-amazing-game");
    commands.insert_resource(
        Persistent::<KeyBindings>::builder()
            .name("key bindings")
            .format(StorageFormat::Toml)
            .path(config_dir.join("key-bindings.toml"))
            .default(KeyBindings { jump: KeyCode::Space, crouch: KeyCode::C })
            .loaded(false)
            //^^^^^^^^^^^^ using this or ".unloaded(true)"
            .build()
            .expect("failed to initialize key bindings")
    )
}

When the persistent resource is unloaded, the underlying resource will not be accessible until it's loaded back to memory using reload method.

fn reload_key_bindings(key_bindings: Res<Persistent<KeyBindings>>) {
    key_bindings.reload().expect("failed to reload key bindings");
}

Reload can also be used on already loaded persistent resources to synchronize with the external changes. This can be very useful in certain situations, such as a save editor. The save editor would update the save files, and calling reload would bring those updates to the game without restarting the game!

You should be careful when using unloaded persistent resources!

// all of these will panic
fn incorrect_usage_of_unloaded_key_bindings(key_bindings: Res<Persistent<KeyBindings>>) {
    // can't access the fields since it's not in memory
    println!("{}", key_bindings.crouch);
    key_bindings.crouch = KeyCode::ControlLeft;

    // can't get the underlying resource since it's not in memory
    key_bindings.get();
    key_bindings.get_mut();

    // can't update, persist, or unload the underlying resource since it's not in memory
    key_bindings.update(...);
    key_bindings.persist();
    key_bindings.unload(); // this will call persist before unloading so it'll panic as well
}

// none of these will panic
fn correct_usage_of_unloaded_key_bindings(key_bindings: Res<Persistent<KeyBindings>>) {
    // can get properties of the persistent resource
    key_bindings.name();
    key_bindings.format();
    key_bindings.storage();
    key_bindings.is_loaded();
    key_bindings.is_unloaded();

    // can try to get the underlying resource
    key_bindings.try_get();
    key_bindings.try_get_mut();

    // can set the resource to a new value
    key_bindings.set(...);

    // can unload without persisting as it's a no-op
    key_bindings.unload_without_persisting(...);

    // can reload the persistent resource
    key_bindings.reload(...);

    // can revert the persistent resource to its default
    key_bindings.revert_to_default(...);
    key_bindings.revert_to_default_in_memory(...);
}

Prettifying

It's a good idea to store some resources with a prettified format during development to easily observe/modify them.

You can use pretty feature to enable prettified textual formats:

[features]
debug = ["bevy-persistent/pretty"]

And in your game:

fn setup(mut commands: Commands) {
    let config_dir = dirs::config_dir().unwrap().join("your-amazing-game");
    commands.insert_resource(
        Persistent::<KeyBindings>::builder()
            .name("key bindings")
            .format({
                #[cfg(feature = "debug")]
                {
                    StorageFormat::JsonPretty
                }
                #[cfg(not(feature = "debug"))]
                {
                    StorageFormat::Json
                }
            })
            .path(config_dir.join("key-bindings.toml"))
            .default(KeyBindings { jump: KeyCode::Space, crouch: KeyCode::C })
            .build()
            .expect("failed to initialize key bindings")
    )
}

Then you can develop your game using:

cargo run --features debug

And to release your game, you can compile using:

cargo build --release

WebAssembly

...is supported!

When building persistent resources, you need to specify a path. Normally, this path is used to specify a location in the filesystem, but there is no filesystem in WebAssembly. Instead, it has local storage and session storage.

Changing the API of the library, or creating a separate API for WebAssembly would make the library complicated to use. Instead, the library uses the fact that the selection of storage can be done using a path.

  • /local/settings/key-bindings.toml would store the persistent resource in local storage with the key settings/key-bindings.toml
  • /session/settings/key-bindings.toml would store the persistent resource in session storage with the key settings/key-bindings.toml

It might seem complicated at first, but makes it really easy to support both Native and WebAssembly application.

use std::path::Path;

fn setup(mut commands: Commands) {
    let config_dir = dirs::config_dir()
        .map(|native_config_dir| native_config_dir.join("your-amazing-game"))
        .unwrap_or(Path::new("local").join("configuration"));

    commands.insert_resource(
        Persistent::<KeyBindings>::builder()
            .name("key bindings")
            .format(StorageFormat::Json)
            .path(config_dir.join("key-bindings.toml"))
            .default(KeyBindings { jump: KeyCode::Space, crouch: KeyCode::C })
            .build()
            .expect("failed to initialize key bindings")
    )
}

With the code above, you don't need to have any conditional compilation to support both Native and WebAssembly application.

  • In Native applications, it'll determine the configuration directory of the platform (e.g., ~/.config) and join the name of your game to it (e.g., ~/.config/your-amazing-game), and use it as the base directory in the filesystem.
  • In WebAssembly applications, it'll use local storage with the base key of configuration, once you join it with key-binding.toml, the resource will be stored using the key configuration/key-bindings.toml.

If the first element of the specified path is not "local" or "session", the library will panic!

If you don't like this approach, and want to be strict with types, you can use the new method of Persistent<R> instead.

fn setup(mut commands: Commands) {
    use bevy_persistent::Storage;

    let name = "key bindings";
    let format = StorageFormat::Toml;
    let storage = Storage::LocalStorage { key: "key bindings".to_owned() };
    let loaded = true;
    let default = KeyBindings { jump: KeyCode::Space, crouch: KeyCode::C };
    let revertible = false;
    let revert_to_default_on_deserialization_errors = false;

    commands.insert_resource(
        Persistent::new(
            name,
            format,
            storage,
            loaded,
            default,
            revertible,
            revert_to_default_on_deserialization_errors,
        )
        .expect("failed to initialize key bindings"),
    );
}

Examples

There are a few examples that you can run directly and play around with in the examples folder.

cargo run --release --features all --example name-of-the-example

To run the examples in a browser using WebAssembly, you can use wasm-server-runner.

cargo run --release --features all --target wasm32-unknown-unknown --example name-of-the-example

Creating plugins on top

If you want to create plugins that use persistent resources, you can use the library feature to avoid specifying storage formats.

[package]
name = "bevy-amazing-library"
version = "0.1.0"
edition = "2021"

[dependencies]
bevy-persistent = { version = "0.3" }

[dev-dependencies]
bevy-persistent = { version = "0.3", features = ["all"] }

[features]
default = []
library = ["bevy-persistent/library"]

You can develop your library with library feature enabled (e.g., cargo build --features library), and bevy-persistent wouldn't complain when building your library, but applications would still get the error.

Relation to

bevy_pkv

bevy_pkv is a generic key value store for Bevy. It is an excellent library, and it can be used as an alternative to bevy-persistent. Here are some of the differences between the two libraries!

  • bevy_pkv is a lot more flexible

Let's say you want to have two different Settings for two different users.

For the time being, this is not straightforward to do with bevy-persistent because Persistent<R> is a resource, there can only be a single instance of a resource. So you can't have two Persistent<Settings> in the same app. You can work around this by defining a custom struct (e.g., Persistent<AllSettings>), using a tuple (e.g., Persistent<(Settings, Settings)>), using a vector (e.g., Persistent<Vec<Settings>>), or using a hashmap (e.g., Persistent<HashMap<Settings>>).

This is very easy to do with bevy_pkv!

fn setup(mut pkv: ResMut<PkvStore>) {
    // ...
    let blue_settings: Settings = ...;
    let red_settings: Settings = ...;
    // ...
    pkv.set("blue-settings", &blue_settings).unwrap();
    pkv.set("red-settings", &red_settings).unwrap();
    // ...
}

fn utilize_settings(pkv: Res<PkvStore>) {
    // ...
    let blue_settings: Settings = pkv.get("blue-settings").unwrap();
    let red_settings: Settings = pkv.get("red-settings").unwrap();
    // ...
}

Maybe bevy-persistent can provide a solution to this problem at some point if it's requested by the community! I really like the idea of providing PersistentVec<R> and PersistentMap<K, R> where K: impl AsRef<str>.

fn setup(mut settings: ResMut<PersistentMap<&'static str, Settings>>) {
    // ...
    let blue_settings: Settings = ...;
    let red_settings: Settings = ...;
    // ...
    settings.insert("blue", blue_settings)?;
    settings.insert("red", red_settings)?;
    // ...
}

fn utilize_settings(settings: Res<PersistentMap<&'static str, Settings>>) {
    // ...
    let blue_settings: &Settings = settings["blue"];
    let red_settings: &Settings = settings["red"];
    // ...
}
  • bevy_pkv is using extremely optimized key-value storage engines (in native apps)

As far as I can see, the current version of bevy_pkv has 2 storage options, sled and RocksDB, which are extremely well optimized. This means if the persistent objects are updated frequently, bevy_pkv performance will be outstanding!

bevy-persistent on the other hand stores each persistent object as a separate file, which means persisting objects trigger direct disk writes! This is okay for most use cases (e.g., settings or manual saves), but if your application requires very frequent updates, bevy_pkv is the way to go!

Mandatory note on this, please please please profile before making decisions for performance reasons!

  • bevy_pkv parses the objects on each read

Writes being extremely optimized, reads can be slow in bevy_pkv because reading a persistent object requires parsing, on each read!

In bevy-persistent, reads are basically free as Persistent<R> is just a wrapper around the actual object. That is unless you unload them using unload or unload_without_persisting methods to save memory. When you load them back using reload method, parsing will happen once and reads will be free again.

Again, always profile before making decisions for performance reasons!

  • bevy_pkv doesn't do automatic management

With bevy_pkv, modifying the object and making the changes persistent is always two different steps.

fn modify_key_bindings(mut pkv: ResMut<PkvStore>) {
    let mut key_bindings = pkv.get::<KeyBindings>("key-bindings");
    key_bindings.crouch = KeyCode::ControlLeft;
    pkv.set("key-bindings", &key_bindings)
}

bevy-persistent on the other hand provides APIs to automate this process (see Modification).

  • bevy_pkv makes external edits very hard (in native apps)

bevy-persistent stores each persistent object as a separate file in the specified format. If a textual format is used, the object becomes extremely easy to edit! The example in Creation will create key-bindings.toml in the specified location with the following contents:

jump = "Space"
crouch = "C"

You can easily change this file to:

jump = "Space"
crouch = "ControlLeft"

And it'd work perfectly in the next run!

bevy_pkv on the other hand stores the objects in a single database in a binary format, which is more efficient but not suitable external modification.

  • bevy_pkv makes storing objects in different locations a bit inconvenient (in native apps)

This one is kinda like the one above, but it's more about how things are structured to satisfy your needs! For example, you may want to synchronize user saves through Steam but not user settings as they need to be different across machines.

This scenario is doable with bevy_pkv, but it requires creating new types that wrap PkvStore within them and using those instead of a single PkvStore, which is a little inconvenient. It's very easy with bevy-persistent however as you can structure your persistent objects however you see fit in the filesystem using a single type!

  • bevy_pkv only supports JSON and Binary storage formats

bevy-persistent supports a wide variety of storage formats! If you're porting a game from another engine to Bevy you might be able to just define the structure of some objects (e.g., settings, saves) and let bevy-persistent handle the rest!

With bevy_pkv however, you need to create a transformation layer as it's not directly compatible with any of the widely used formats.

  • bevy_pkv is not type safe

bevy-persistent integrates with type system of Bevy. Types of persistent resources are specified in system definitions and everything is handled by Bevy.

bevy_pkv on the other hand provides a single resource which you can use to query a persistent key value database with any type in the runtime. This is very flexible, but you need to specify types on each access, so it's error-prone. Also, you can't see what the system is doing easily without looking to the function body.

  • bevy_pkv can obstruct parallelism

Each persistent resource in bevy-persistent is a separate resource. Which means systems can be scheduled to access/modify different persistent resources at the same time.

This cannot be the case with bevy_pkv as it has a single resource type (pkv: Res<PkvStore> or mut pkv: ResMut<PkvStore>) for all systems, which prevents concurrent reads/writes on different persistent objects.

License

bevy-persistent is free, open source and permissively licensed, just like Bevy!

All code in this repository is dual-licensed under either:

This means you can select the license you prefer!

About

A Bevy helper to easily manage resources that need to persist across game sessions.

License:Apache License 2.0


Languages

Language:Rust 100.0%