Introduction
This library provides an experimental hot reload system for Bevy.
Features
- Define the reloadable areas of your game explicitly - which can include systems, components, state, events and resources (w/ some limitations)
- Reset resources to a default or pre-determined value upon reload
- serialize/deserialize your reloadable resources & components, allowing you to evolve their schemas so long as they are compatible with the de-serializer (using rmp_serde)
- mark entities to get removed on hot reload
- run systems after hot-reload
- create functions to set up & tear down upon either entering/exiting a state or on hot reload
- only includes any hot reload capacity in your build when you explicitly enable it - such as by using the CLI launcher
Additional Resources
We also have API Docs
Credits
This project was inspired by DGriffin91's Ridiculous bevy hot reloading.
Installation
Grab the CLI by running: cargo install dexterous_developer_cli
.
You'll be able to run the dexterous verion of your code by running dexterous_developer_cli run
in your terminal.
In your Cargo.toml
add the following:
[lib]
name = "lib_THE_NAME_OF_YOUR_GAME"
path = "src/lib.rs"
crate-type = ["rlib"]
[dependencies]
bevy = "0.13"
dexterous_developer = "0.1"
serde = "1" # If you want the serialization capacities
[package.metadata]
hot_reload_features = ["bevy/dynamic_linking", "bevy/embedded_watcher"] # this injects these features into the build, enabling the use of bevy's dynamic linking and asset hot reload capacity.
If your game is not a library yet, move all your main logic to lib.rs
rather than main.rs
. Then, in your main.rs
- call the bevy_main function:
fn main() { lib_NAME_OF_YOUR_GAME::bevy_main(); }
and in your lib.rs
, your main function should become:
#![allow(unused)] fn main() { #[hot_bevy_main] pub fn bevy_main(initial_plugins: impl InitialPlugins) { App::new() .add_plugins(initial_plugins.initialize::<DefaultPlugins>()) // You can use either DefaultPlugins or MinimnalPlugins here, and use "set" on this as you would with them // Here you can do what you'd normally do with app // ... and so on } }
You might also want the following in your .cargo/config.toml
:
# Optional: Uncommenting the following improves compile times, but reduces the amount of debug info to 'line number tables only'
# In most cases the gains are negligible, but if you are on macos and have slow compile times you should see significant gains.
#[profile.dev]
#debug = 1
# Enable a small amount of optimization in debug mode
[profile.dev]
opt-level = 1
# Enable high optimizations for dependencies (incl. Bevy), but not for our code:
[profile.dev.package."*"]
opt-level = 3
[profile.dev.package.gfx-backend-vulkan]
opt-level = 3
debug-assertions = false
Note that the CLI will automatically add some additional build flags for dynamic linking, but you can include them in the config.toml as well:
# Add the contents of this file to `config.toml` to enable "fast build" configuration. Please read the notes below.
# NOTE: For maximum performance, build using a nightly compiler
# If you are using rust stable, remove the "-Zshare-generics=y" below.
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-Clink-arg=-fuse-ld=lld", "-Zshare-generics=y"]
# NOTE: you must install [Mach-O LLD Port](https://lld.llvm.org/MachO/index.html) on mac. you can easily do this by installing llvm which includes lld with the "brew" package manager:
# `brew install llvm`
[target.x86_64-apple-darwin]
rustflags = [
"-C",
"link-arg=-fuse-ld=/usr/local/opt/llvm/bin/ld64.lld",
"-Zshare-generics=y",
]
[target.aarch64-apple-darwin]
rustflags = [
"-C",
"link-arg=-fuse-ld=/opt/homebrew/opt/llvm/bin/ld64.lld",
"-Zshare-generics=y",
]
[target.x86_64-pc-windows-msvc]
linker = "rust-lld.exe"
rustflags = ["-Zshare-generics=n"]
Adding Reloadables to your Plugins
You can add reloadable portions to your application within any plugin, but they have to be explicitly segmented and defined. Within a plugin, you would add a reloadable section like so:
#![allow(unused)] fn main() { impl Plugin for MyPlugin { fn build(&self, app: &mut App) { app .setup_reloadable_elements::<reloadable>(); } } #[dexterous_developer_setup] fn reloadable(app: &mut ReloadableAppContents) { app .add_systems(Update, this_system_will_reload); } }
Here, we are defining a setup function reloadable
, and adding it to our app in the plugin builder. This function will be called any time the library is reloaded.
In the background, the plugin runs cargo watch
on the source directory of the project, and whenever it re-builds we will load a new version of the library.
Types of Reloadables
Systems
The simplest, and most versatile, type of reloadable is a simple system. This can be any system - we just register it in a special way allowing us to remove them when thge library is replaced.
These are added using .add_systems
, exactly like you would add a system to a normal bevy app!
Setup Systems
These are special systems, that are supposed to set things up - which might need to be re-done upon a reload. The classic example is a game UI, which you might want to re-build after a reload. There are a few helpers for these types of systems
First, we can clear all entities that have a marker component and then run our setup function using .reset_setup::<Component>(systems_go_here)
.
And if we want it to only happen on entering a specific state (or re-loading while within that state), we can use .reset_setup_in_state::<Component>(state, systems)
.
Alternatively, if we just want to clear stuff out on a reload, we can use a marker component and call .clear_marked_on_reload::<Component>()
.
Resources
Reloading resources is a little more complex - so we have a few variations
Reset on Reload
If you want to reset a resource when the library reloads, you can use either .reset_resource::<Resource>()
which uses its default value, or .reset_resource_to_value(value)
which uses a value you provide.
Replaceable Resources
If you want to be able to iterate on the structure of a resource, but maintain it's existing data via serialization, you can use a ReplacableResource
. To do so you need to implement the ReplacableResource
trait on your type:
#![allow(unused)] fn main() { #[derive(Resource, Serialize, Deserialize, Default)] struct MyResource; impl ReplacableResource { fn get_type_name() -> &'static str { "my_resource" } } }
Then, you can register it using .init_replacable_resource::<ReplacableResource>()
. This will cause the resource to be serialized before the library is reloaded, and replaced with a new version after reload. Since serialization is done using msgpack, it should be able to cope with adding new fields or removing old ones - but keep in mind the way serde handles that kind of stuff.
Replacable Components
You can also set up replacable components. These function like replacable resources, but involve replacing components on various entities. Here you implement the ReplacableComponent
trait:
#![allow(unused)] fn main() { #[derive(Component, Serialize, Deserialize, Default)] struct MyComponent; impl ReplacableComponent { fn get_type_name() -> &'static str { "my_component" } } }
And then register it using .register_replacable_component::<ReplacableComponent>()
.
Replacable State
States are also possible to set up as replacable. Here you implement the ReplacableState
trait:
#![allow(unused)] fn main() { #[derive(States, PartialEq, Eq, Clone, Copy, Debug, Hash, Default, Serialize, Deserialize)] pub enum AppState { #[default] A, B, } impl ReplacableState for AppState { fn get_type_name() -> &'static str { "app-state" } fn get_next_type_name() -> &'static str { "next-app-state" } } }
Note that unlike ReplacableResource
or ReplacableComponent
, with ReplacableState
you need to give it a name as well as giving a name for the NextState<S>
resource it'll create.
You can then add the state using .init_state::<ReplacableComponent>()
.
Replacable Event
You can also create replacable Events. Here you implement the ReplacableEvent
trait:
#![allow(unused)] fn main() { #[derive(Event, Clone, Debug, Serialize, Deserialize)] pub enum AppEvent { Text(String), Shout(String) } impl ReplacableEvent for AppEvent { fn get_type_name() -> &'static str { "app-event" } } }
Note that when events get replaced it resets the event queue - so all existing events will be cleared! Since as a rule events only persist to the next frame generally, this shouldn't be too much of an issue - depending on when you trigger the reload.
You can then add the state using .add_event::<ReplacableEvent>()
.
Use with the CLI
The CLI provides a bunch of supporting functionality.
Run
The run
command allows you to launch a package in hot reload mode.
If you are working in a non-workspace package, you can just run dexterous_developer_cli run
.
If you are working in a workspace with multiple libraries set up, you will need to specify the package containing your game with dexterous_developer_cli run -p PACKAGE_NAME
.
If you want to enable or disable features, use --features
to add the ones you want. Note that "bevy/dynamic_linking" and "dexterous_developer/hot_internal" will always be added, since they are required for the reloading capacity to work.
Another option is to use --example EXAMPLE_NAME
- which will run the example as hot-reloadable, assuming the example is set up as a dylib. Note - this does not work if the crate itself is set as a dylib - so it's best to rely on the CLI's ability to use a temporary Cargo.toml when needed.
Serve
The serve
commands sets up a hot-reload build server, allowing you to connect to it via the remote
command on another machine or serve from a dev container and run the application on the host. Currently it only supports cross compiling from Linux to Windows, otherwise both devices must be of the same platform.
Remote
This is the compliment to the serve
command.
Install Cross
The install-cross
installs the rust targets required for cross compilation. If you want to use a MacOS target, you need to provide the URL of a macos sdk. All cross compilation is based on Cross - and so requires either docker or podman.
Run Existing & Compile Libs
The compile-libs
command creates the same libraries as the compiler for "serve", while run-existing
can take a directory with the appropriate libraries and run it. This is mainly there to allow testing cross-platform builds in CI, but can also be used to run the most-recently served version of the application without re-connecting to the server.
Running Without the CLI
To run without the CLI, you need to create a new launcher crate within the same workspace. The recommended approach is to use cargo workspaces to do so. Within that crate, you only really need one dependency:
[dependencies]
dexterous_developer = { version = "0.2.0", default-features = false, features = [
"hot",
"cli",
] }
Then in the main.rs
file, you'd want to trigger the launcher using run_reloadable_app
- like so:
use dexterous_developer::HotReloadOptions; fn main() { dexterous_developer::run_reloadabe_app(HotReloadOptions { package: Some("NAME_OF_YOUR_GAME_PACKAGE".to_string()), ..Default::default() }) }
You will also need to add dylib
to the crate type of your main library, or create a separate dynamic library crate that will be loaded by the launcher. When using the CLI it creates a temporary manifest file that adds the dylib - so it's not needed with the CLI.
The HotReloadOptions can also contain things like features, a custom library name, the watch folder, and the target folder - but it should infer most of that from the package.
You would then run the game using cargo run -p NAME_OF_THE_LAUNCHER
.
Mold and Alternative Linkers
When running on Linux, and not cross-compiling, you can pass in alternative linkers like mold using the DEXTEROUS_DEVELOPER_LD_PATH
environment variable. The value then gets inserted as link-arg=-fuse-ld={DEXTEROUS_DEVELOPER_LD_PATH}
in the rustc compiler arguments.
Reload Settings
This is where we'll be adding settings that can be adjusted during runtime when you are hot reloading.
To add reload settings, insert the ReloadSettings
resource in your application:
#![allow(unused)] fn main() { .insert_resource(ReloadSettings::default()) }
which is equivalent to:
#![allow(unused)] fn main() { .insert_resource(ReloadSettings { display_update_time: true, manual_reload: Some(KeyCode::F2), toggle_reload_mode: Some(KeyCode::F1), reload_mode: ReloadMode::Full, reloadable_element_policy: ReloadableElementPolicy::OneOfAll(KeyCode::F3), reloadable_element_selection: None, }) }
Display Update Time
This setting will display the most recent update time in the window title for your game, letting you know whether the reload has happened yet. This is useful for subtle changes or situations where you are unsure whether things worked, where you'd be able to look at that timestamp and determine if it's recent enough to be your most recent change.
Reload Mode
The reload mode controls the specific elements that get re-loaded:
- Full (the default) - this runs a full hot reload, including systems, reloadable resources and components, and cleanup/setup functions
- SystemAndSetup - this reloads systems and runs cleanup/setup functions, but doesn't re-set, serialize or de-serialize resources and components
- SystemOnly - this reloads systems and does nothing else
Manual Reload
This allows you to set a key (defaults to F2) that will trigger a reload based on the current reload mode - without needing to make a code change. This is useful if you want to manually re-set resources or trigger setup functions.
Toggle Reload Mode
This allows you to set a key (defaults to F1) that will cycle between reload modes.
Reloadable Element Policy
This allows you to only enable more complex reload (cleanup/setup functions and/or serialization/deserialization) for one "Reloadable Element" at a time. A reloadable element is the function that sets up all the reloadable portions of the app - in the example, it's shown as:
#![allow(unused)] fn main() { #[dexterous_developer_setup] fn reloadable(app: &mut ReloadableAppContents) { ... } }
In your app, you can treat these similarly to plugins, and have more than one of them. However - their names mustn't conflict on a global scale, and to help with that you can pass in an additional parameter to the macro. Here is a little mock up:
#![allow(unused)] fn main() { #[hot_bevy_main] pub fn bevy_main(initial_plugins: impl InitialPlugins) { App::new() ... .setup_reloadable_elements::<first::reloadable>() .setup_reloadable_elements::<second::reloadable>() ... } mod first { #[dexterous_developer_setup(first_reloadable)] fn reloadable(app: &mut ReloadableAppContents) { ... } } mod second { #[dexterous_developer_setup(second_reloadable)] fn reloadable(app: &mut ReloadableAppContents) { ... } } }
The Reloadable Element Policy allows you to determine how, and if, you want to handle reloading each of them. Specifically - it lets you decide to only fully re-load one of them, while others will only re-load updated systems but not run any setup/cleanup or serialization/deserialization. This is useful if you are working on a specific element, for example the UI, that requires running a setup function to re-build it - but where you don't want to necessarily re-run the setup for other systems.
There are 3 possible values:
- All - with this policy, all elements are always reloadable - and no toggling is available.
- OneOfAll(KeyCode) - with this policy you provide a key that you can use to cycle between all the reloadable elements in your project.
- OneOfList(keyCode, Vec<&'static str>) - with this policy you can provide a hard-coded subset of elements you want to allow cycling between. This is mostly useful for situations where your application is complex enough that there are too many reloadable elements to cycle through, but you might want to alternate work between a subset of them. To set up the list, you should use the
your_reloadable_function::setup_function_name()
.
Reloadable Element Selection
This is an optional value that defaults to "None". As a rule, it is recommended to leave it as is. However, if necessary - you can use to to pre-set a specific reloadable element that will be focused on. If the policy allows for cycling between elements, that will still be possible - it just changes the initial default. If you want to set it, set it with the setup_function_name method: Some(your_reloadable_function::setup_function_name())
.