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:
- The first generation of the cellular automaton.
- 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ĀClapcrate. 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 populartracing-subscriberĀ crate.TaskPoolPlugin, for managing task poolsTypeRegistrationPlugin, which provides low-level support for the type-based resource registration that we saw aboveFrameCountPlugin, for counting framesTimePlugin, which adds support for discrete time and timersTransformPlugin, to enable entity placement and transformationHierarchyPlugin, for building component hierarchiesDiagnosticsPlugin, for collecting various execution and performance metricsInputPlugin, which provides access to keyboard, mouse, and gamepad inputWindowPlugin, for cross-platform windowing supportAccessibilityPlugin, 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:
- 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Ā toJustifyContent::Center. - In the child entityāsĀ
TextBundleāsĀStyle, setĀalign_selfĀ toAlignSelf::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.
Our Ideas
Explore More Blogs

From Kickoff to Code: How We Built a World Cup App with GitHub Copilot
Randy Pagels, Trish Roberts
Contact



