Reactivity in Bevy: From the Bottom Up (Part 1)

·

10 min read

In this article, I'm going to talk about how I've been developing bevy_reactor, a general framework for reactive programming in the Bevy game engine. Inspired by Ryan Carniato's Reactive Framework from Scratch series, I'm going to take a bottom up approach that starts with the most primitive layers of the system, and gradually works up to higher-level concepts like user interface widgets.

This is Part 1. Part 2 will talk about reactive contexts, and Part 3 will discuss views.

What is Reactivity?

The term "reactive programming" has gained a lot of popularity in recent years, but many people misunderstand the term, thinking it that applies to any "interactive" program. However, reactivity has a precise technical definition: it means a technique of programming where the mere act of reading a data source creates an implicit dependency on that data source. In other words, instead of the classic "observer" pattern, where you have to manually subscribe and unsubscribe to some observable object, in a reactive environment subscribing and unsubscribing is handled automatically.

Mutables

While bevy_reactor works with existing Bevy components and resources, many reactive library features need a way to manage small amounts of state that can be declared and managed with minimal coding and no pre-registration. The Mutable<T> type serves this need.

The Mutable<T> type is actually just a wrapper around an entity id and a component id. The actual data is stored in a MutableCell<T>, which is a Component that simply wraps the stored data in a newtype struct. The Mutable<T> wrapper is a handle which provides access to the cell, and gives us a couple of interesting things:

  • Because Mutable<T> is generic, it lets us carry around the type of the mutable data in the handle, so getting and setting the content of the mutable always uses the correct type.

  • Because Mutable<T> implements Copy, it can easily be passed as a parameter or used in a closure (which is important because we're going to have a lot of closures!)

  • Reading from a Mutable<T> automatically adds a reference to the mutable to a tracking context (we'll talk a bit more about what that means later). This is what powers reactive updates.

  • Because Mutable<T> also contains the component id of the MutableCell<T>, we can check for changes to the component (using Bevy's change detection mechanism) without knowing it's generic type.

  • Reading and writing a Mutable<T> almost always requires a reference to the current Bevy World, either directly or wrapped in a context object. This is because code that uses mutables is often deeply nested within a method or widget hierarchy. The root caller of this code is often unaware of exactly which mutables will be accessed during the call. So we mostly can't use Bevy's dependency injection to access data in mutables, since that requires coding the function signature to declare up front which data we're going to access.

  • Because mutable data is stored in a Component, it has to be Send + Sync + 'static, just like any other component data.

  • Writing a Mutable<T> doesn't change its value immediately; instead, a Command is issued to update the value. The reason for this is consistency: mutables tend to get passed around as parameters and struct fields, and we want every reader to "see" the same state, regardless of the order in which they are run.

The Mutable type is pretty simple, and looks like this:

/// Contains a reference to a reactive mutable variable.
#[derive(PartialEq)]
pub struct Mutable<T> {
    /// The entity that holds the mutable value.
    pub(crate) cell_id: Entity,
    /// The component id for the mutable cell.
    pub(crate) component_id: ComponentId,
    /// Marker
    pub(crate) marker: std::marker::PhantomData<T>,
}

Reading and Writing Mutables

Reading and writing a mutable generally requires a context object of some sort. This is because mutables are actually entities. To read a mutable, the context object must implement ReadMutable, and to write a mutable, it needs to implement WriteMutable. Both of these types are implemented for Bevy World, so it's possible to access mutables via a direct reference to a world:

// Read from mutable
let data = my_mutable.get(world);
// Write to mutable
my_mutable.set(world, new_value);

However, this is not actually how you want to access mutables most of the time. Accessing mutables using a direct world reference bypasses the reactive tracking mechanism - you'll get the data all right, but it won't be reactive.

The preferred way to access a mutable is to use a Cx or Rcx context object. The Cx context supports reading, writing as well as creating new mutables and other reactive primitives; while Rcx is a read-only context that only supports immutable access. Both of these context types contain an internal "tracking scope" which records every access to reactive objects such as mutables.

let data = my_mutable.get(cx);
my_mutable.set(cx, new_value);

Internally, the call to .get() does two things: it adds the mutable id to the context's tracking scope, and then retrieves the mutable data using the World reference inside of Cx.

The .get() method on Mutable<T> actually has several variants, depending on whether the T type supports either Copy or Clone. If the type supports Copy, then you can just call .get(). If the data supports .clone(), then .get() won't work, but you can call .get_clone() instead:

// Returns a clone of what's in the mutable.
let data = my_mutable.get_clone(cx);

If the T type supports neither Copy nor Clone, then you can call .as_ref() which returns a reference to the data in the mutable.

Creating Mutables

The Cx type has a number of convenient helper methods for creating a mutable:

let selected = cx.create_mutable::<bool>(false);

This does three things:

  • Spawns an entity with a MutableCell component.

  • Adds the entity id to the list of owned entities for that context.

  • Wraps the entity id in a Mutable and returns it.

Signals

A Mutable<T> can also be converted into a Signal<T> object. Signals are getter-like objects: they have .get() and .get_clone() methods very similar to the methods on a mutable. However, while a signal might point to a mutable, it can also point to other things: derived computations, memoized computations, or even a constant. The key here is that the code that reads the signal only knows that it's a way to get some data, but doesn't care where that data lives or how it's stored. It's kind of like a Future in that respect. This lets us "plug in" various kinds of reactive sources to a data consumer which depends on the values produced by those sources.

Signals also have a .map() method, which can be used to transform the value returned by the signal. This is similar to what Option::map does, except that this method is reactive: calling .map() adds the signal source to the current tracking context.

Internally, a Signal<T> is just an enum which has variants for mutables and other things:

#[derive(Copy)]
pub enum Signal<T> {
    /// A mutable variable that can be read and written to.
    Mutable(Mutable<T>),
    /// A readonly value that is computed from other signals.
    Derived(Derived<T>),
    /// A memoized value that is computed from other signals.
    Memo(Memo<T>),
    /// A constant value, useful for establishing defaults.
    Constant(T),
}

Tracking Scopes

We mentioned tracking scopes earlier, now it's time to explain what actually they are.

In most reactive frameworks, a tracking scope consists of:

  • A set of dependencies.

  • A "reaction" function which is called (not necessarily immediately) when those dependencies change.

However, this is Bevy and we're doing things a little bit differently, because...ECS! Reactive scopes are modeled as entities, and the "tracking" part and the "reaction" part are actually split into separate Components. The reason for this is that there are several different flavors of reactions.

The TrackingScope component looks like this:

#[derive(Component)]
pub struct TrackingScope {
    /// List of child entities that are owned by this scope,
    /// which are to be despawned when this scope is destroyed.
    owned: Vec<Entity>,
    /// Set of components that we are currently subscribed to.
    component_deps: HashSet<(Entity, ComponentId)>,
    /// Set of resources that we are currently subscribed to.
    resource_deps: HashMap<ComponentId, TrackedResource>,
    /// (some other details omitted...)
}

In other words, the scope is simply a way to keep track of which components and resources were accessed during some execution. There's an ECS system which iterates through all of the tracking scopes and checks to see if any of the dependencies changed; if so, then a "reaction" is invoked.

Note that this change detection relies on polling, and happens fairly late in the Bevy system order. This is by design: by relying on Bevy's built-in change detection, we automatically "debounce" multiple updates to the same mutable, and avoid problems with "reaction loops" whereby the code which reacts to a variable also mutates the same variable, causing the code to go into an infinite loop. It is still possible to have a reaction loop, but there iswill eventually be code which does a "convergence check" to print a warning if the system detects that the number of queued updates is not converging to zero.

The other half of the puzzle is reactions. There's a generic Reaction component, and a special reaction component for Views, which will be discussed in a later section. A Reaction is simply a trait that supports a .react() method:

pub trait Reaction {
    /// Update the reaction code in response to changes in dependencies.
    ///
    /// Arguments:
    /// - `owner`: The entity that owns this reaction and tracking scope.
    /// - `world`: The Bevy world.
    /// - `tracking`: The tracking scope for the reaction.
    fn react(&mut self, owner: Entity, world: &mut World, tracking: &mut TrackingScope);
}

A reaction is normally contained in a ReactionHandle, which is a Component:

/// A reference to a reaction.
pub type ReactionRef = Arc<Mutex<dyn Reaction + Sync + Send + 'static>>;

/// Component which contains a reference to a reaction. Generally the entity will also
/// have a [`TrackingScope`] component.
#[derive(Component)]
pub struct ReactionHandle(pub(crate) ReactionRef);

So the code for reactive updates is fairly simple: there's an ECS system which finds all the tracking scopes for which there are updated dependencies; then, for each such scope, it clears the list of dependencies and then runs the reaction.

This is an important detail: the set of dependencies for a tracking scope is cleared each time there's a reaction. The reaction code is then expected to "re-subscribe" any data that is used during the reaction by accessing the data again. This is how Solid.js, MobX and other reactive frameworks work, and it's how we can get away with not having to manually unsubscribe a bunch of listeners, or have to deal with memory leaks.

At this point, an alert reader might ask, "What happens if I don't access the data every time? For example, suppose I have a reaction which accesses a data source conditionally? Won't that break the algorithm?". The answer to this is yes, it will break, but there's an easy way around this problem: as long as the condition itself is a reactive data source, everything will work - because whenever that condition changes, it will force a reaction.

This does mean that when working with reactive data sources, one needs to be careful to access them in a consistent way.

Derived Computations

Now that we have mutables and signals squared away, it's time to look at the next reactive primitive, derived computations. A Derived<T> is very similar to a Mutable<T>, except instead of wrapping a data value, it wraps a closure. The actual closure is stored in a DerivedCell<T>:

#[derive(Component)]
pub(crate) struct DerivedCell<R>(pub(crate) Arc<dyn DerivedFnRef<R> + Send + Sync>);

Derived<T> is simply a copyable, cloneable, type-safe handle for referencing a derived cell:

#[derive(PartialEq)]
pub struct Derived<R> {
    pub(crate) id: Entity,
    pub(crate) marker: std::marker::PhantomData<R>,
}

A derived computation must return a value of type T. Like a mutable, a derived can be converted into a Signal<T>. The difference, however, is that when you access the signal, instead of adding the derived to the tracking scope directly, the derived closure is executed, and any mutables or signals it uses are tracked (the derived is passed a cx parameter which is meant for that purpose). This means that if any data that is used by the derived is changed, then any caller of the derived will react.

A Memo<T> is an advanced kind of Derived<T> which memoizes its output - that is, users of the value don't react unless the output actually changed. Internally, this is implemented as an entity that has both a derived and a mutable (to store the memozied value).

Creating Deriveds

Creating a new derived is pretty easy, there are methods on Cx:

let a = cx.create_mutable::<bool>(false);
let b = cx.create_mutable::<bool>(false);
let either = cx.create_derived(move |cx| a.get(cx) || b.get(cx));

These entities are "owned" by the current tracking scope contained in Cx, and will be automatically despawned when that scope is destroyed.

Next Steps

In Part 2, we'll dive deeper into the context object, Cx. In Part 3, I discuss how to create interactive views.