Skip to content

New crate: bevy_user_prefs#22770

Open
viridia wants to merge 18 commits intobevyengine:mainfrom
viridia:user_prefs
Open

New crate: bevy_user_prefs#22770
viridia wants to merge 18 commits intobevyengine:mainfrom
viridia:user_prefs

Conversation

@viridia
Copy link
Contributor

@viridia viridia commented Feb 1, 2026

Objective

  • Add framework for managing user preferences on both desktop and WASM platforms.

Design doc: https://hackmd.io/@dreamertalin/rkhljFM7R

Testing

  • Unit tests, manual testing on both desktop and wasm

Additional Information

This PR is likely to be highly controversial, it incorporates a number of design decisions which are likely to be contentious.

@alice-i-cecile alice-i-cecile added A-Editor Graphical tools to make Bevy games X-Needs-SME This type of work requires an SME to approve it. M-Release-Note Work that should be called out in the blog due to impact S-Needs-Review Needs reviewer attention (from anyone!) to move forward C-Feature A new feature, making something new possible labels Feb 1, 2026
#[derive(Debug, Default)]
pub struct PreferencesFile {
pub(crate) table: toml::Table,
changed: AtomicBool,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we track changes per group (or even per setting) ? An App that doesn't autosave settings might want to indicate each unsaved change in its settings page.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This use case seems speculative. I try to avoid writing code based on hypothetical requirements, as I find this to be a major source of scope creep.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This use case seems speculative.

It seems pretty standard to me. It's needed for games that offer players lots of knobs to twiddle.

}

/// Load a preferences file from disk in TOML format.
pub(crate) fn decode_toml_file(file: &PathBuf) -> Option<toml::Table> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logging might not be initialized when preferences are read. Perhaps this should return a result instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean that I can't call warn() in main? That would be very surprising to me.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean that I can't call warn() in main? That would be very surprising to me.

That's correct. Logging events might be missed if they're emitted before the log plugin is added. I usually do something like this.

fn main() -> AppExit {
  let mut app = App::new();

  // setup logging early
  app.add_plugins(LogPlugin::default());

  pre_bevy_setup(); // this can use logging

  app.add_plugins(DefaultPlugins.disable::<LogPlugin>());

  app.run()
}

@viridia
Copy link
Contributor Author

viridia commented Feb 3, 2026

I'm still thinking about whether to return Result instead of Option on load.

Most of the time, users will not call load directly, but will call get which creates a new preferences file if it does not already exist. However, get will fail if the preferences_dir cannot be located - signaling that a future attempt to save preferences will also fail.

Applications will likely call get in many places: each individual subsystem which has configuration settings may call get individually. Some of these calls may happen in main, while others may happen within ECS systems. If the preference system is not functioning (because the preferences dir cannot be found) we probably don't want each individual subsystem reporting the same error.

I would rather have a failure of the preferences system be reported once; but this is hard to do because it gets called in many places, unless the error is handled within the preference system itself. Unfortunately, we don't have a lot of options here - an app shouldn't panic, because it can still run without preferences.

@viridia viridia marked this pull request as ready for review February 4, 2026 00:45
@Person-93
Copy link
Contributor

What if you find/create the file in Preferences::new ? That seems like the right spot to report if there's something wrong with the whole file. Then Preferences::default can return a preferences table not backed by an actual file.

User code might look like this:

let prefs = Preferences::new("com.example.my-app").unwrap_or_else(|e| {
    // user can log the error, set an AtomicBool, or whatever else they want
    default()
});

If they don't care to do anything about the error, they can call unwrap_or_default.


fn main() {
// Configure preferences store
let mut preferences = Preferences::new("org.bevy.example.prefs");
Copy link
Member

@cart cart Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think my big "problem" with this proposal is that it feels disconnected from Bevy / it is something that you "bind" to it, and that comes at the cost of features and clarity

I also think preferences / settings should be something that ultimately largely replace Plugin settings in their current form, and they should be strongly typed as the default interface into them (as in, they are defined and accessed as Rust types). I think type safety (as in a strong pairing between the "name" of a preference and its expected type) is extremely valuable, especially in cases like the enums. With your proposed system, if a preference is expected to be some enum PlayerTeam { Red, Blue } enum, the consumer of the preference needs to know (and successfully write out) the "player_team" string, and they need to know that it corresponds to the PlayerTeam enum when choosing how to interpret the raw untyped value assigned to "player_team".

I think something like the following API will make our users happier, significantly improve the UX, and feel more "integrated" with Bevy in general. As written, it relies on Resources as Components (currently in review, soon to be merged), Reflection, and a "SettingsPlugin" running immediately after the reflection type registry is initialized (but before plugins that use settings).

#[derive(SettingGroup, Reflect)]
#[settings_group(parent = AppSettings)]
struct PlayerSettings;

// A `Setting` is a Resource that is expressed as an "immutable component" to make change tracking bulletproof
// It also sets up an _on_insert_ component hook that detects when Team has changed in code and kicks off persistence
// to the settings file (if auto save is enabled)
#[derive(Setting, Reflect, Default)]
#[setting_group(PlayerSettings)]
#[reflect(Default)]
enum Team {
    #[default]
    Red,
    Blue,
}

#[derive(Setting, Reflect)]
#[setting_group(PlayerSettings)]
#[reflect(Default)]
struct Name(String);

impl Default for Name {
    fn default() -> Self {
        Name("Anonymous".into())
    }
}

struct PlayerPlugin;

impl Plugin for PlayerPlugin {
    // Note that this happens _after_ the settings have been initialized
    fn build(&self, app: &mut App) {
        match *app.resource::<Team>() {
            Team::Red => {/* do red things here */},
            Team::Blue => {/* do blue things here */},
        }
    }
}

fn main() {
    App::new()
        .insert_resource(AppSettings("my_studio.my_game"))
        .add_plugins((DefaultPlugins, PlayerPlugin))
        .add_systems(Update, read_team_setting)
        .add_observer(on_team_change)
        .run();
}

fn read_team_setting(team: Res<Team>) {
    println!("The currently configured team is {}", team);
}

fn on_team_change(team: On<Insert, Team>) {
    println!("This observer detected a team change: {}", team);
}

The serialized form stored in CONFIG_DIR/my_studio.my_game/app.toml
(only non-default settings are serialized)

[player]
team = "Red"

Representing settings as entities/components means they will be inspectable / integrate with whatever tooling we have there. If we make SettingGroup a resource too, we could then represent settings as relationship hierarchies (making them easily explorable / editable in inspectors).

An interesting side-effect of this approach is that we could in theory represent settings as BSN. (ex: Parent/child hierarchies of settings components). Although I'm not convinced theres a reason to do that.

This structured approach also allows us to detect cases where settings from different authors step on each other's toes. It also allows us to define migrations more effectively (if that is an area we or our users want to pursue).

There are some downsides with my proposal. We need to express each setting individually as a component, which introduces some boilerplate. It also creates "naming" questions, as Team could easily also be a normal component or resource type (decoupled from settings). Theres also the issue of discoverability (but frankly your proposal suffers from that to an even greater degree, by nature of having no source of truth for a given setting). I believe the naming and discoverability problems are largely resolved by using a setting submodule. We could also adopt a naming convention like NameSetting.

Being able to access settings individually by the type is also a double edged sword. It makes it low-boilerplate to read, but it also decouples it from the wider context (ex: in my example, to access Team you don't need to go through the PlayerSettings symbol at all). I think the ability to granularly react to a specific setting change in observers is well worth that price of admission.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's one other downside to your approach, although maybe not super important: you might want to organize the properties in the settings file differently than they are organized in memory.

Take Minecraft for example: there's a settings page which has several different logical sections, one of which is "graphics". Within this page there are various settings such as "MSAA" and "Chunk Render Distance". From a user ergonomics standpoint, all of these are grouped together under the category of "graphics settings".

However, MSAA and Chunk Render Distance are very different, and are used by different parts of the graphics pipeline. While one could group all of the graphics-related settings together in a single struct, I can also imagine that a developer might not want to.

Again, I don't know how important this is, whether developers would feel significantly constrained by this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you might want to organize the properties in the settings file differently than they are organized in memory.

I'm not sure this critical. I think its worth pointing out that you can group settings in a UI however you want, so this really only applies to the file format, which is likely only going to be accessed by advanced users.

However with the "one resource per setting" approach, I think the "in memory organization" thing barely comes into play (as consumers of the setting consume it directly, rather than accessing it through some organizational hierarchy), so you can treat the "groupings" as the user-facing contract (which is the serialized contract, but it could in theory also be reflected in the UI).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I'm not sure why you say that "resources as components" is necessary for this. I've prototyped this approach before, using reflection annotations (iter over all resources and scoop up the ones that have a prefs annotation). The piece I was missing - why I turned away from that approach - was that I didn't have the "initialize before build()" piece, which would have required mucking around with App::build; at the time I thought that be too controversial to try and get adopted. This resulted in preferences being loaded too late.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The piece I was missing - why I turned away from that approach - was that I didn't have the "initialize before build()" piece

This has become a non-issue with the new-ish "automatic Reflect type registration", as plugins no longer need to register their types, and the types are available immediately after the TypeRegistry initializes.

Also, I'm not sure why you say that "resources as components" is necessary for this

I didn't mean to say that it was blocking, only that it was necessary to produce the code snippet I showed. "Resources as components" is what enables writing observers for each setting (and enables making those resources "immutable components" )

github-merge-queue bot pushed a commit that referenced this pull request Mar 10, 2026
Yet another attempt at implementing bevy preferences. This version uses
bevy_reflect serialization to convert resources from toml values into
Rust types and vice versa. This is based on the feedback that I got from
the earlier attempt in #22770

To indicate that a resource type should be loaded as preferences, you'll
need to add the `SettingsGroup` annotation:

```rust
#[derive(Resource, SettingsGroup, Reflect, Default)]
#[reflect(Resource, SettingsGroup, Default)]
struct Counter {
    count: i32,
}
```

This will produce a TOML file that looks like this:

```toml
[counter]
count = 3
```

## Theory of Operation

The `PreferencesPlugin` scans the type registry for all resource types
that impl `SettingsGroup` and `Default`. Derive attributes can be used
to write the resource to a different file (or different key in browser
local storage).

`PreferencesPlugin` should be added before other plugins. This ensures
that any other plugins can have access to the settings data during
initialization.

The loader checks to see if the resource already exists; if so, it uses
that resource instance and patches the toml values into it, preserving
any defaults that have been set. If the resource does not exist, it
constructs a new one via `ReflectDefault` before applying the toml
properties.

(There was a suggestion of using `FromWorld` instead of `Default`. This
is worth considering, although there may be issues with calling
`FromWorld` so early in the app initialization lifecycle, before most
resources have been created.)

On `wasm` platforms, this uses browser local storage rather than the
filesystem to store preferences. On platforms which have neither,
preferences are not supported (although it's possible that some
platform-specific settings storage could be implemented).

## Note on terminology

I've tried to consistently use the term "preferences" rather than
"settings" or "config" because those are broader terms. For example, the
`xorg.conf` file, commonly used to configure an XWindows display, is
technically a "settings" file, but it is not "preferences". However, for
end users it's perfectly permissible to use the word "Settings" in menus
and navigation elements since that is the term most commonly used in
software today.

## Open Issues

### Syncing with non-resources

Some important settings are not stored in resources: one of the most
common things that users will want to preserve is the window position
and size, which exist on the window entity. It's not possible, under my
design, to store arbitrary entities as preferences, so in order for the
window properties to be saved they will have to be copied to a resource
before being serialized. We probably don't want to be continually
copying the window size every time the window is dragged or moved, so
we'll need some way to know when serialization is about to happen. I'm
thinking that possibly some global event could be triggered just before
serialization, and the handlers could use this event to make last-minute
patches to resources.

## Saving If Changed

Because saving involves i/o, we want to only save when preferences have
actually changed. This involves two discrete checks:

* Whether a save operation needs to be done at all
* Which files need to be saved

The reason for these two steps is that even checking which files need to
be saved is non-trivial and probably should not be done every frame.

Rather than check the `is_changed()` field of every preference resource
every frame, the code currently relies on the user to issue an explicit
`Command` whenever they change a preferences property. This gets
especially tricky if the settings to be saved aren't actually in a
resource, like the aforementioned window position.

There are two forms of the command: `SavePreferences` and
`SavePreferencesSync`. The former, which uses an i/o task, is the
preferred approach, unless the app is about to exit, in which case the
sync version is preferred.

Once we know that a save will take place, a second pass can be used to
check the timestamp on every resource: if any resource has a tick value
later than the last time the file was either loaded or saved, then we
know that file is out of date.

Also some properties can change at high frequency - for example,
dragging the master volume slider changes the volume every frame. For
this reason, we will generally want to put in a delay / debounce logic
to batch updated together (not present in this PR). However, this delay
means that if the user adjusts the setting and then immediately
terminates the app, the setting won't be recorded. (There is no chance
of the file being corrupted, as it uses standard practices for ensuring
file integrity.)

Unfortunately, on some platforms, depending on how the user chooses to
quit (Command-Q on Mac) there's no opportunity to listen for the
`AppExit` event. For this reason, it's best to use a "belt and
suspenders" approach which listens for both `AppExit` and autosave timer
events.

Fixes #23172
Fixes #13311

---------

Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
Co-authored-by: Kevin Chen <chen.kevin.f@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Editor Graphical tools to make Bevy games C-Feature A new feature, making something new possible M-Release-Note Work that should be called out in the blog due to impact S-Needs-Review Needs reviewer attention (from anyone!) to move forward X-Needs-SME This type of work requires an SME to approve it.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants