Blog

Cellular Automata Using Rust: Part II

Todd Smith

Todd Smith

Updated January 28, 2026
22 minutes
ā€Œ

In theĀ first postĀ of this three-part series, we described elementary cellular automata and modeled them in theĀ RustĀ programming language. In this post, the second of the three-part series, we describe the theory underlying entity-component-system (ECS) architecture, how theĀ BevyĀ game engine operationalizes this into practice, how to set up and streamline Bevy for cross-platform development, and how to build a static user interface using Bevy. You may want to keep theĀ fully-worked projectĀ handy while you read.

Entity-component-system (ECS) architecture

We started with theory, moved to practice, and now it’s time for some more theory. Before we dive into usingĀ Bevy, let’s first make a pit stop to learn aboutĀ entity-component-systemĀ (ECS) architecture.

In ECS architecture, a discrete event simulator subjects numerousĀ entitiesĀ toĀ systemsĀ that govern their lifecycles and mediate their interactions through operations on their statefulĀ components.

  • AnĀ entityĀ is an opaque atom of identity. Typically devoid of any intrinsic properties, it can usually be represented with a simple integer.
  • AĀ componentĀ ascribes a role to an entity and encapsulates any data necessary to model that role. Components may be affixed to entities permanently or transiently, and are usually maintained extrinsically, i.e., mapped onto an entity through an external data structure. In a physics simulation, a ā€œrigid bodyā€ component might adorn every entity that represents a physical object; the ā€œrigid bodyā€ component could include state to model mass, linear drag, angular drag, and so forth.
  • AĀ systemĀ embodies a process that acts only on entities that instantaneously possess some target combination of components. Systems can: inject entities into the simulation; delete entities from the simulation; attach components to entities; detach components from entities; modify the state inside components; manage global resources; and interface with other application modules.

ECS is common in video games and simulations, but works well whenever applications are founded upon data-oriented design principles. It fits snugly alongside other paradigms, like object-oriented or functional programming, taking an orthogonal approach to solving related problems of structure and composition.

Bevy

BevyĀ is a data-driven game engine with a fast, flexible ECS. It’s relatively new, but it’s also powerful and cross-platform, with support for 2D and 3D render pipelines, scene persistence, cascading style sheets (CSS), and hot reloading. Its build system permits fast recompilation, so you spend more time testing than waiting. It also integrates smoothly with numerous popular crates, likeĀ SerdeĀ (for serialization) andĀ eguiĀ (for buildingĀ immediate-mode graphic user interfaces). We’re barely going to scratch the surface of what Bevy can do in this project.

Bevy’s entities areĀ generational indices. Its components are structs and enums: ordinary data types for which you can implement theĀ ComponentĀ trait, which you typically do just by derivingĀ Component. Its systems are ordinary functions whose signatures are built-in up from types that implement theĀ SystemParamĀ trait; these types are provided by the Bevy framework, and many of them are generic over (your own) component types.

If this is too abstract, don’t worry. We’ll put it together one piece at a time with concrete examples.

Setting up cross-platform Bevy

Let’s get Bevy wired up for both native and web development and deployment. We’ll go through it step-by-step, but if you need more instructions, you can check out theĀ officialĀ orĀ unofficialĀ Bevy setup tips.

InĀ Cargo.toml, we add not one, butĀ twoĀ dependencies for Bevy.

[dependencies.bevy]
version ="0.12.0"
[target.'cfg(not(target_family = "wasm"))'.dependencies.bevy]
version ="0.12.0"
features =["dynamic_linking"]

The first section brings Bevy into the project using the default set of features, so long as there isn’t an override for a more specific configuration. Naturally, the second section is such an override; in particular, this override enables dynamic linking of the Bevy crate, which speeds up your application development cycle. Dynamic linking is only available for native targets, not WebAssembly (WASM), hence the conditionality.

Now we need to instruct Cargo to benefit from dynamic linking. InĀ .cargo/config.toml, we provide the various platform-specific configurations.

[target.x86_64-unknown-linux-gnu]
linker ="clang"
rustflags =["-Clink-arg=-fuse-ld=lld"]
[target.x86_64-apple-darwin]
rustflags =[
 "-C",
 "link-arg=-fuse-ld=/usr/local/opt/llvm/bin/ld64.lld"
]
[target.aarch64-apple-darwin]
rustflags =[
 "-C",
 "link-arg=-fuse-ld=/opt/homebrew/opt/llvm/bin/ld64.lld"
]
[target.x86_64-pc-windows-msvc]
linker ="rust-lld.exe"
[target.wasm32-unknown-unknown]
runner ="wasm-server-runner"

We support Linux, macOS, and Windows on x86; macOS on AArch64; and web on WASM. As recommended by the folks behind Bevy, we useĀ lld, the linker supplied withĀ LLVM. You don’t have to getĀ lld, but it’s recommended for fastest link-time performance, which translates directly into less time waiting for builds to complete. If you don’t haveĀ lldĀ already and don’t want to install it, you can just replace the paths to your preferred linker. If you do want to installĀ lld, you can follow theĀ installation instructionsĀ provided by Bevy.

TheĀ runnerĀ key in the WASM section at the end specifies a Cargo plugin,Ā wasm-server-runner, that enables you to useĀ cargo run --target wasm32-unknown-unknownĀ to test a WASM build. You can install it withĀ cargo install wasm-server-runner.

It took a little bit of work, but Bevy is ready to go — on five platforms.

Cross-platform program arguments

It would be nice to let the user set the initial conditions for the evolution. There are two interesting configuration parameters:

  1. The first generation of the cellular automaton.
  2. The rule by which the cellular automaton will evolve from one generation to the next.

As we saw above, we can describe a cellular automaton with aĀ u64Ā and a rule with aĀ u8, and useĀ fromĀ to obtain our model types. But there’s enough complexity to parsing command-line arguments that we want to delegate that responsibility to a mature third-party crate:Ā Clap. Let’s bring it into the project by adding this toĀ Cargo.toml:

[target.'cfg(not(target_family = "wasm"))'.dependencies.clap]
version ="4.4.8"
features =["derive"]

Back inĀ src/main.rs, we bundle our configuration parameters into a struct with a declarative strategy, letting Clap do the hard work for us:

/// Fun with cellular automata! Set the first generation with a known seed
/// and/or rule, or let the program choose randomly. Watch the automaton evolve,
/// and influence its evolution with the keyboard and mouse.
#[derive(Debug,Default)]
#[cfg_attr(not(target_family ="wasm"), derive(Parser))]
structArguments
{
 /// The rule, specified as a Wolfram code between 0 and 255, inclusive. If
 /// unspecified, the rule will be chosen randomly.
 #[cfg_attr(not(target_family ="wasm"), arg(short, long))]
 rule:Option<u8>,
 /// The first generation, specified as a 64-bit integer that represents the
 /// complete population. Lower numbered bits correspond to cells on the
 /// right of the visualization. If unspecified, the first generation will be
 /// chosen randomly.
 #[cfg_attr(not(target_family ="wasm"), arg(short, long))]
 seed:Option<u64>
}

There’s some apparent complexity, so let’s unpack:

  • We deriveĀ DebugĀ andĀ Default, because both are handy.
  • When the target isn’t WASM, we deriveĀ clap::Parser, which will generate all the necessary boilerplate to parse our arguments from the command line.
  • When the target isn’t WASM, we supply theĀ argĀ attribute from theĀ Clap crate. This primes the generated parser with a short form, long form, and description of the argument; the description is taken directly from the doc comment, which is why I included it in the excerpt. You want to avoid relying on any fancy Rustdoc formatting, because Clap will dump that formatting directly to standard output when the program is run withĀ --help.
  • ruleĀ andĀ seedĀ are both optional. We randomize whatever the user doesn’t specify.
  • Clap also emits the doc comment for the struct itself as the summary of the program, so the same caveats apply as above; keep it simple and address the user directly.

That handles the native case, but the web doesn’t have command line arguments. It does, however, have a query string comprisingĀ search parameters, which play an analogous role to command-line arguments. We pop over toĀ Cargo.tmlĀ one more time to register a conditional dependency onĀ web-sys:

[target.'cfg(target_family = "wasm")'.dependencies.web-sys]
version ="0.3.65"
features =["Location","Url","UrlSearchParams"]

web-sysĀ partitions the enormous web API using crate features. We need to access theĀ Location,Ā Url, andĀ UrlSearchParamsĀ types in order to build our own simple search parameter parser, so we specify the eponymous features.

Oh, while we’re still looking at the build file, we might as well do one more thing. We promised randomization, so let’s bring in theĀ randĀ crate to handle that. We’ll insert it right beforeĀ ringbuffer, to keep things alphabetized.

[dependencies]
rand ="0.8.5"
ringbuffer ="0.15.0"

We can implement the two cases now. Back over toĀ src/main.rsĀ now! For native, we just wrap the result of Clap-generatedĀ parseĀ in aĀ Some:

#[cfg(not(target_family ="wasm"))]
fn arguments()->Option<Arguments>
{
 Some(Arguments::parse())
}

For web, we do a little bit more work, but it closely tracks the web APIs:

#[cfg(target_family ="wasm")]
fn arguments()->Option<Arguments>
{
 let href = web_sys::window()?.location().href().ok()?;
 let url = web_sys::Url::new(&href).ok()?;
 let params = url.search_params();
 let rule = params.get("rule").and_then(|rule| rule.parse().ok());
 let seed = params.get("seed").and_then(|seed| seed.parse().ok());
 Some(Arguments{ rule, seed })
}

We called the functionĀ argumentsĀ in both cases, and exercised care to give it the same signature, so we can use the same name and conventions to call it on native and web.

Giving control to Bevy

We have the arguments now, so it’s time to use them. Let’s see howĀ mainĀ initializes Bevy and hands over control to its engine loop.

fn main()
{
 let args = arguments().unwrap_or(Arguments::default());
 let rule = args.rule
 .and_then(|rule|Some(AutomatonRule::from(rule)))
 .unwrap_or_else(|| random::<u8>().into());
 let seed = args.seed
 .and_then(|seed|Some(Automaton::<AUTOMATON_LENGTH>::from(seed)))
 .unwrap_or_else(|| random::<u64>().into());
 App::new()
 .insert_resource(
 History::<AUTOMATON_LENGTH, AUTOMATON_HISTORY>::from(seed)
 )
 .insert_resource(rule)
 .add_plugins(AutomataPlugin)
 .run();
}

Here we see the call toĀ arguments, which binds to the correct implementation based on compilation target. As promised, there’s nothing special about the call — it’s a perfectly ordinary function call. If it fails for any reason, we plug in the defaultĀ Arguments, which will cause both the rule and first generation to be randomized.

AppĀ is our gateway into the Bevy framework. We won’t be referring to it directly after initialization, but it holds onto the world, the runner, and the plugins. TheĀ worldĀ is the complete collection of system elements that compose the application model. TheĀ runnerĀ is the main loop that processes user input, evolves the world over time, and controls rendering. AndĀ pluginsĀ are pre-packaged mini-worlds: collections of resources and systems that can be reused across many projects.

AĀ resourceĀ is a global singleton with a unique type. Systems access resources via dependency injection. We useĀ insert_resourceĀ to register both the rule and the history, making them available for dependency injection into our systems. Anything that derivesĀ ResourceĀ can be used as a resource. In the first blog post of the series, we derivedĀ ResourceĀ for bothĀ AutomatonRuleĀ andĀ History, and now you know why!

AutomataPluginĀ is the plugin that bundles together all of our other resources and systems. We attach it viaĀ add_plugins. Finally, we callĀ runĀ to hand control over to Bevy. From here on, the engine’s main loop is responsible for all execution.

Modular composition with plugins

Perhaps surprisingly, our plugin is entirely stateless. Over inĀ src/ecs.rs, where we’re going to spend the rest of our time, we see:

pubstructAutomataPlugin;

Stateless is fine, because we only care about the plugin’s behavior, which is to finish initializing the application. For that, we implement theĀ PluginĀ trait:

implPluginforAutomataPlugin
{
 fn build(&self, app:&mutApp)
 {
 let _seed = app.world.get_resource::<History>()
 .expect("History resource to be inserted already");
 let rule = app.world.get_resource::<AutomatonRule>()
 .expect("AutomatonRule resource to be inserted already");
 letmut window =Window{
 resolution:[1024.0,768.0].into(),
 title: rule.to_string(),
 ..default()
 };
 set_title(&mut window,*rule);
 app
 .add_plugins(DefaultPlugins.set(WindowPlugin{
 primary_window:Some(window),
 ..default()
 }))
 .add_plugins(FrameTimeDiagnosticsPlugin)
 .insert_resource(EvolutionTimer::default())
 .insert_resource(AutomatonRuleBuilder::default())
 .add_systems(Startup, add_camera)
 .add_systems(Startup, build_ui)
 .add_systems(Update, maybe_toggle_instructions)
 .add_systems(Update, accept_digit)
 .add_systems(Update, maybe_show_fps)
 .add_systems(Update, maybe_toggle_cells)
 .add_systems(Update, update_next_rule)
 .add_systems(Update, maybe_change_rule)
 .add_systems(Update, evolve)
 .add_systems(Update, update_fps);
 }
}

There’s no requirement for a plugin to be stateless, soĀ buildĀ borrows both the plugin and theĀ App. We use the statically polymorphicĀ get_resourceĀ to extract the seed and the rule that we registered inĀ main. Note that we pull these resources using their static types only, which is why every resource needs a unique static type. This is not a problem, because if we want to register, say, 20 strings, we can wrap each in a disparate newtype first; newtypes have zero runtime cost, and also provide better semantics, so this restriction guides us toward better modeling decisions. We don’t use the seed at all, but its availability is an assertable precondition for installing our plugin, so we extract it anyway.

We use the rule to set the title for theĀ Window. On native systems, this affects the title bar of the window. But in WASM,Ā WindowĀ maps onto aĀ canvas, which doesn’t have a title bar. We’ll need a cross-platform mechanism to handle this properly, so we’ll revisit this below.

DefaultPluginsĀ aggregates the standard plugins that are widely useful across most projects:

  • LogPlugin, a logging plugin built on top of the popular tracing-subscriberĀ crate.
  • TaskPoolPlugin, for managing task pools
  • TypeRegistrationPlugin, which provides low-level support for the type-based resource registration that we saw above
  • FrameCountPlugin, for counting frames
  • TimePlugin, which adds support for discrete time and timers
  • TransformPlugin, to enable entity placement and transformation
  • HierarchyPlugin, for building component hierarchies
  • DiagnosticsPlugin, for collecting various execution and performance metrics
  • InputPlugin, which provides access to keyboard, mouse, and gamepad input
  • WindowPlugin, for cross-platform windowing support
  • AccessibilityPlugin, a plugin to manage and coordinate integrations with accessibility APIs

TheĀ setĀ method onĀ DefaultPluginsĀ lets us replace one of these plugins. We manually supplyĀ window, which we already created and customized, to serve as the primary window for the application.

After adding the basic plugins, we insert two more resources, one to manage the evolution rate and the other to buffer user input when entering a new rule. Finally, we pour in all the systems that together define the behavior of our application. Bevy groups systems together into predefinedĀ schedules. TheĀ StartupĀ schedule runs exactly one time, during initialization of the application, so systems may be recorded here to perform some nonrecurring setup logic. TheĀ UpdateĀ schedule runs once per iteration of the engine loop.Ā add_systemsĀ associates a system with a schedule, and incorporates that association into the world.

Setting the window title

Before diving into the various systems, let’s take a short detour to reach catharsis about window titles. We abstracted the logic out toĀ set_title, so that we could specialize behavior differently for native and web.

The native implementation is quite trivial. We already have aĀ Window, and theĀ WindowĀ has a title bar, so it’s a simple matter of updating a field:

#[cfg(not(target_family ="wasm"))]
fn set_title(window:&mutWindow, rule:AutomatonRule)
{
 window.title = rule.to_string();
}

The web implementation isn’t too much harder, but it does require remembering how web works. At the root of the namespace isĀ window, which holds onto a complete browser window. A window has aĀ document, which is the root for the page nodes, organized according to the web’s Document Object Model (DOM). A document has a title, which is displayed in the document tab or window title bar (for rare non-tabbed browsers).Ā web-sysĀ models the web APIs closely, so we can follow this chain of custody directly:

#[cfg(target_family ="wasm")]
fn set_title(_window:&mutWindow, rule:AutomatonRule)
{
 web_sys::window().unwrap().document().unwrap().set_title(&rule.to_string());
}

unwrapĀ is safe here because our host application is a web browser. Either call would fail only for a headless host, like Node.js, where a graphical cellular automaton simulator wouldn’t even make sense.

Camera

Bevy is a game engine that supports multiple scenes, so it serves a broader, more general purpose than a run-of-the-mill UI toolkit. Entities don’t just appear in our window because we place them, we need to watch them through a camera. If you don’t add a camera, then you’ll be staring at a pitch black window.

fn add_camera(mut commands:Commands)
{
 commands.spawn(Camera2dBundle::default());
}

add_cameraĀ is a system that we added to theĀ StartupĀ schedule. Its argument,Ā Commands, is our interface to the Bevy command queue, which allows us to spawn entities, add components to entities, remove components from entities, and manage resources.

spawnĀ creates a new entity with the specified component attached. The argument can be anything, so long as it represents a bundle. AĀ bundleĀ is just a batch of components, and any component can be construed as a batch of one. In terms of traits,Ā spawnĀ expects an implementation of the traitĀ Bundle, and every type that implements the traitĀ ComponentĀ also automatically implements the traitĀ Bundle. Bevy implementsĀ BundleĀ for tuples of components, so this makes it handy to spawn an entity with multiple components attached.

Camera2dBundleĀ aggregates the many components that together provide a view onto some scene. The default instance provides an orthographic projection, so lines and parallelism are preserved at the expense of distances and angles. For our purposes, this ensures that all cells will appear congruent regardless of their distance to the lens.

The user interface

There are essentially four user interface elements in our application:

All UI Elements Visible
  • The most obvious is the grid of cells that represents the history of the cellular automaton. As mentioned before, each automaton comprises 64 cells, and we retain 50 generations. Each cell has a black border, and is filled black if it’s ā€œonā€ and white if it’s ā€œoffā€. The bottom row represents the newest generation, so the generations scroll bottom-to-top over time as the evolution runs. We want to let the user toggle the newest generation between ā€œonā€ and ā€œoffā€, so we use clickable buttons for the last row, and we fill the button with yellow when hovered, as a discoverable indication of interactivity. None of the other cells are interactive, so mere rectangles suffice.
  • The semitransparent banner near the top of the grid contains abbreviated instructions to guide the user toward supported keyboard interactions. This banner is shown only when the simulator is paused. Naturally, the simulator begins paused so that the user can see the banner a learn a bit about what behaviors are supported.
  • The semitransparent banner in the lower left shows the next rule that will run. This banner appears when the user presses a digit, either on the number row or on the number pad, remains on the screen while the user is typing additional digits, and disappears when the user is finished. So if the user types ā€œ121ā€, the banner will first show ā€œ1ā€, then ā€œ12ā€, and finally ā€œ121ā€. If the user types an invalid rule number, like ā€œ500ā€, then the banner will show ā€œErrorā€.
  • The semitransparent banner in the lower right shows the instantaneousĀ frames per secondĀ (FPS), which is the rendering rate for the graphical pipeline, i.e., how often the view redraws. The iteration rate for the engine loop is measured inĀ ticks per secondĀ (TPS), where a tick is a single iteration. Some game engines separate these two concepts, but Bevy ties them together directly, so $FPS = TPS$. FPS therefore gives us a coarse performance metric. This banner only appears while the user holds down the right shift key.

TheĀ build_uiĀ system belongs to theĀ StartupĀ schedule. We are going to create the UI elements only once, then mutate them in place from our systems. Only the call graph rooted atĀ build_uiĀ will spawn entities, and these entities survive until the application terminates.

fn build_ui(history:Res<History>,mut commands:Commands)
{
 commands
 .spawn(NodeBundle{
 style:Style{
 height:Val::Percent(100.0),
 width:Val::Percent(100.0),
 ..default()
 },
 background_color:BackgroundColor(Color::DARK_GRAY),
 ..default()
 })
 .with_children(|builder|{
 build_history(builder,&history);
 build_instruction_banner(builder);
 build_next_rule_banner(builder);
 build_fps_banner(builder);
 });
}

This system also receives access to the command queue, but something new is happening.Ā ResĀ is the injection point for theĀ HistoryĀ that we registered as a resource.Ā ResĀ acts like an immutable borrow, here providing us read access to the wholeĀ History. Just by including this parameter, Bevy knows statically to inject theĀ HistoryĀ that we registered. Of course, it’s possible to forget to register a resource; in this case, Bevy will panic at runtime prior to invoking a system that requires the missing resource. You generally find such problems immediately when you run the application, so it’s not a big deal that the registration check happens at runtime.

build_uiĀ spawns an entity to represent the whole user interface. That entity serves as the root of the containment hierarchy that includes the four major elements mentioned above, each of which encompasses its own constituent sub-elements.Ā NodeBundleĀ is the component type that serves as the basic UI element.Ā StyleĀ supports a sizable subset of the features of Cascading Style Sheets (CSS), includingĀ FlexboxĀ andĀ Grid. Here we ensure that the element will occupy all available space in the window.

Bevy passes aĀ ChildBuilderĀ toĀ with_children, which permits hierarchical composition of entities. We pass it into each of our subordinate UI element builders.

History view

In build history, we lay out the grid that visualizes the evolution of our cellular automaton over the last fifty generations:

fn build_history(builder:&mutChildBuilder, history:&History)
{
 builder
 .spawn(NodeBundle{
 style:Style{
 display:Display::Grid,
 height:Val::Percent(100.0),
 width:Val::Percent(100.0),
 aspect_ratio:Some(1.0),
 padding:UiRect::all(Val::Px(24.0)),
 column_gap:Val::Px(1.0),
 row_gap:Val::Px(1.0),
 grid_template_columns:RepeatedGridTrack::flex(
 AUTOMATON_LENGTH as u16,1.0),
 grid_template_rows:RepeatedGridTrack::flex(
 AUTOMATON_HISTORY as u16,1.0),
 ..default()
 },
 background_color:BackgroundColor(Color::DARK_GRAY),
 ..default()
 })
 .with_children(|builder|{
 for(row, automaton) in history.iter().enumerate()
 {
 for(column, is_live) in automaton.iter().enumerate()
 {
 cell(builder,CellPosition{ row, column },*is_live);
 }
 }
 });
}

We use CSS Grid to ensure that the cells are uniformly sized. In the closure passed toĀ with_children, we iterate through the complete history to emit the cells.Ā CellPositionĀ is a custom component:

#[derive(Copy,Clone,Debug,Component)]
structCellPosition
{
 row: usize,
 column: usize
}

Just as derivingĀ ResourceĀ is sufficient to permit a type’s use as a resource, derivingĀ ComponentĀ is sufficient to permit a type’s use as a component. As the placement loop illustrates,Ā rowĀ increases top-to-bottom, whileĀ columnĀ increases left-to-right.

fn cell(builder:&mutChildBuilder, position:CellPosition, live: bool)
{
 builder
 .spawn(NodeBundle{
 style:Style{
 display:Display::Grid,
 padding:UiRect::all(Val::Px(2.0)),
 ..default()
 },
 background_color: liveness_color(true),
 ..default()
 })
 .with_children(|builder|{
 if position.is_active_automaton()
 {
 builder.spawn(
 (
 ButtonBundle{
 background_color: liveness_color(live),
 ..default()
 },
 position
 )
 );
 }else{
 builder.spawn(
 (
 NodeBundle{
 background_color: liveness_color(live),
 ..default()
 },
 position
 )
 );
 }
 });
}

We emit a visual cell with the eponymousĀ cellĀ function. We indulge in some CSS Grid chicanery to surround our cell with a 2px border.Ā is_active_automatonĀ answersĀ trueĀ if and only if the row corresponds to the newest generation, so we use it choose whether to attach a clickableĀ ButtonBundleĀ component or an inactiveĀ NodeBundleĀ component. We set the cell color withĀ liveness_color, which produces black for ā€œonā€ cells and white for ā€œoffā€ cells.

If you look carefully, you’ll see thatĀ spawnĀ is receiving 2-tuples — the UI bundle and ourĀ CellPosition. The resultant entity will have both components attached. This will end up being important when we run theĀ evolveĀ system.

Instruction banner

Building the instruction banner is very similar, but contains a few new pieces:

fn build_instruction_banner(builder:&mutChildBuilder)
{
 builder
 .spawn(
 (
 NodeBundle{
 style:Style{
 display:Display::Flex,
 position_type:PositionType::Absolute,
 height:Val::Px(50.0),
 width:Val::Percent(100.0),
 padding:UiRect::all(Val::Px(8.0)),
 top:Val::Px(50.0),
 justify_content:JustifyContent::Center,
 ..default()
 },
 background_color:BackgroundColor(
 Color::rgba(0.0,0.0,0.0,0.8)
 ),
 ..default()
 },
 Instructions
 )
 )
 .with_children(|builder|{
 builder.spawn(
 TextBundle::from_section(
 "[space] to resume/pause,[right shift] to 
 show FPS, or type a new rule",
 TextStyle{
 font_size:28.0,
 color: LABEL_COLOR,
 ..default()
 }
 )
 .with_style(Style{
 align_self:AlignSelf::Center,
 ..default()
 })
 );
 });
}

Since we are creating an overlay, we use absolute positioning. We make the background mostly opaque to provide enough contrast to read the instructional text label. We attach a customĀ InstructionsĀ component to the overlay. This is a stateless marker component that tags the overlay for easy access later.

#[derive(Component)]
structInstructions;

Inside the overlay, we place aĀ TextBundleĀ that holds and styles the desired text. AĀ TextBundleĀ comprises multiple sections, each of which sports different text. This supports easy piecemeal substitution — your label can have static and dynamic portions, and you just swap out the dynamic portions whenever they change. Nothing needs to change in this label, however, so we employ but a single section.

While there are several centering strategies that ought to have worked, there are shortcomings in the CSS implementation, and I only found one strategy that worked reliably in all cases:

  • In the parent entity’sĀ Style, setĀ displayĀ toĀ Display::Flex.
  • In the parent entity’sĀ Style, setĀ justify_contentĀ to JustifyContent::Center.
  • In the child entity’sĀ TextBundleā€˜sĀ Style, setĀ align_selfĀ to AlignSelf::Center.

Save yourself some time and follow those steps if you want to center text in Bevy!

Next-rule banner

The next-rule banner presents the buffered user input that contributes toward ingestion of the next rule. It’s so similar toĀ build_instruction_bannerĀ that we can ignore most of the code, focusing just on what’s different:

fn build_next_rule_banner(builder:&mutChildBuilder)
{
 builder
 .spawn(
 (
 NodeBundle{
 style:Style{
 display:Display::None,
 position_type:PositionType::Absolute,
 height:Val::Px(50.0),
 width:Val::Px(300.0),
 padding:UiRect::all(Val::Px(8.0)),
 bottom:Val::Px(50.0),
 left:Val::Px(50.0),
 ..default()
 },
 background_color:BackgroundColor(
 Color::rgba(0.0,0.0,0.0,0.8)
 ),
 ..default()
 },
 NextRule
 )
 )
 .with_children(|builder|{
 builder
 .spawn(
 (
 TextBundle::from_sections([
 TextSection::new(
 "Next up: ",
 TextStyle{
 font_size:32.0,
 color: LABEL_COLOR,
 ..default()
 },
 ),
 TextSection::from_style(TextStyle{
 font_size:32.0,
 color: LABEL_COLOR,
 ..default()
 })
 ]),
 NextRuleLabel
 )
 );
 });
}

We attach a customĀ NextRuleĀ component instead of anĀ InstructionsĀ component, but it serves the same purpose — to give this entity a systemic identity that is uniquely addressable within the application.

#[derive(Component)]
structNextRuleLabel;

This time we hand an array ofĀ TextSections toĀ TextBundle::from_sections. The first we treat as static text, and the second as dynamic. In particular, we update the second section to show the currently buffered next rule. We attach another custom marker component,Ā NextRuleLabel, to theĀ TextBundle.

#[derive(Component)]
structNextRuleLabel;

FPS banner

The FPS banner is identical to the next-rule banner except for position, specific text, and marker components. We substituteĀ "FPS: "Ā forĀ "Next up:", theĀ FpsĀ component for theĀ NextRuleĀ component, and theĀ FpsLabelĀ component for theĀ NextRuleLabelĀ component.

#[derive(Component)]
structFps;
#[derive(Component)]
structFpsLabel;
fn build_fps_banner(builder:&mutChildBuilder)
{
 builder
 .spawn(
 (
 NodeBundle{
 style:Style{
 display:Display::None,
 position_type:PositionType::Absolute,
 height:Val::Px(50.0),
 width:Val::Px(200.0),
 padding:UiRect::all(Val::Px(8.0)),
 bottom:Val::Px(50.0),
 right:Val::Px(50.0),
 ..default()
 },
 background_color:BackgroundColor(
 Color::rgba(0.0,0.0,0.0,0.8)
 ),
 ..default()
 },
 Fps
 )
 )
 .with_children(|builder|{
 builder
 .spawn(
 (
 TextBundle::from_sections([
 TextSection::new(
 "FPS: ",
 TextStyle{
 font_size:32.0,
 color: LABEL_COLOR,
 ..default()
 },
 ),
 TextSection::from_style(TextStyle{
 font_size:32.0,
 color: LABEL_COLOR,
 ..default()
 })
 ]),
 FpsLabel
 )
 );
 });
}

Sweet, we are done with building up the user interface. In the next and final part of this three-part blog series we will add dynamism — evolution and user interactivity.

Written by

Todd Smith

Rust Solution Architect at Xebia Functional. Co-maintainer of the Avail programming language.

Contact

Let’s discuss how we can support your journey.

ā€Œ
ā€Œ
ā€Œ
ā€Œ
ā€Œ
ā€Œ
ā€Œ
ā€Œ
ā€Œ