Rust Roguelike Overexplained: Movement 1
Introduction
Last time we setup our development environment and started a skeleton application in Rust using Bevy. Today, we will start displaying the traditional @ for the player and use the keyboard to move it around.
Entity Component System
Before we start programming, we need to understand the architectural pattern called “entity component system” or ECS. It is the backbone of Bevy. Software architecture is the term we use when we describe how the different parts of the software interact with each other. A clean and good software architecture helps you reason about the software and makes it easy to extend it with desired functionality. Software architecture makes trade-offs - ideally the right ones - so that functionality you need can be added easily whereas other things can’t be done easily.
ECS is a software architecture pattern that evolved in the gaming industry and their needs. It deals with Entities or “things” in your program that have properties or Components in ECS terms. They are processed using algorithms or Systems. The ECS programming pattern is great when you can’t easily create strong hierarchies (as in this is-a …) like in OOP. It allows free composition of Components in entities compared to OOP, that favors strong types with properties related to those types. Systems process matching entities i.e. entities that have the required components for the algorithm. A scheduler determines when each system is run.
Last time, we already saw an entity with components and a system.
fn setup(mut commands: Commands) {
commands.spawn(Camera2d);
}
The setup function is a system (with no requirements). .add_systems(Startup, setup) scheduled it to run at Startup. spawn creates an entity. Camera2d is a bundle. Bundles are templates for creating entities with specific components.
Display the Player
Creating a Player Component
#[derive(Component)]
struct Player;
This declares that the struct Player is a new Bevy component. Last time, we learned that structs create new data types in Rust. This struct is empty and has no members - it only is. These empty types are called tag types. The only purpose of those types is to identify things. In terms of ECS, the player tag allows us to identify entities that are players and not - for example - monsters.
#[derive(Component)] invokes macro-magic again. Bevy uses this to generate the code it requires for components. This type of macro invocation is very common in Rust and used in many places. derive is used in many places to provide default implementations e.g., #[derive(PartialEq)] for an implementation that allows you to compare the type with ==. Rust Unit Tests use #[test] to identify tests.
Displaying the @ Sign
Extend the setup function to look like this:
fn setup(mut commands: Commands) {
commands.spawn(Camera2d);
commands.spawn((
Text2d::new("@"),
TextFont {
font_size: FontSize::Px(24.0),
font: default(),
..default()
},
TextColor(Color::WHITE),
Transform::from_translation(Vec3::ZERO),
Player,
));
}
The second spawn command uses a Rust tuple as a bundle. Tuples are similar to structs - they combine multiple values, but have no name. If you create the same combination of types as tuple over and over again, consider giving it a name and making it a struct. Since we have only one call here, a tuple is sufficient. You can recognize the tuple by the extra pair of () compared to the spawn(Camera2d) call above.
Important If you are not used to writing programs in Rust or any programming language for that matter, it is highly likely that you don’t know what’s important and what’s not important to look at. I highly recommend you to NOT copy the examples from this blog post but type it. You will make mistakes (e.g. forgetting the extra pair of brackets or a semi colon, adding a space where none is allowed, …) and you’ll have to search for the difference between what was typed and what is written in the example. But that will train your brain to recognize what’s important and what’s not. Learning to program is in that sense similar to learning a language. Just because you can read and understand a language, does not mean you can write your own sentences.
The tuple consists of multiple elements:
- a
Text2d, created with thenewfunction. It takes as parameter the text to be displayed - in our case the @ symbol. - a
TextFont. It is constructed using the normal construction syntax with{}where the struct members are initialized in the formmember: value. There is a specialty though -..default(). This means that the missing members of the struct are initialized with the values from thedefault()construction method. TextColorspecifies a color for the text. In this case viaColor::WHITE, a predefined constant. It can also be defined via the Color class in various different ways.Transformdescribes the position of an entity via translation, rotation & scale. In this case, it is generated via a construction method that simply takes a translation vector & sets the rotation to 0 and scale to 1.Vec3::ZEROis a constant of a 3 dimensional vector where all f32 floating point coordinates are 0. f32 is a 4-byte floating-point number. The other common floating-point format is f64. They differ in range and precision of the numbers they can handle. For more details, see Wikipedia.- Our
Playertag-type to identify this entity as the player.
Feel free to play around with the code. Things you can try out:
- change the value of the text
- change the font height
- change the color
- change the position
revert the changes back once you feel confident about the effect of the different components.
Floating-Point Knowledge
Floating-point numbers in computers allow storing values of a wide range. The precision that can be expressed depends on the value. The larger the value, the lower the precision. They are similar to the scientific notation with a fixed number of digits. Take this example with 3 digits precision: although 111E-3 is equal to 0.111 and thus can express values in the 1/1000 range, 1112 & 1113 are indistinguishable in this notation - they are both 111E1.
The IEEE754 standard defines many different variants, the most common one being float & double (or f32 and f64 in Rust). Each variant differs in the amount of bits they assign to the mantissa (the number of digits before the E in the scientific notation) and the exponent (the number after the E). But the encoding and rules for calculation stay the same for each format.
The representation & calculation rules result in some quirks that are worth knowing:
- the closer to 0 you are, the more precise you can be. Float is approx. 7 digits precise at maximum, Double is approximately 16 digits precise.
- Special numbers: IEEE754 supports special numbers - plus and minus infinity and “not a number” (NaN). These are “sticky” in a sense that you can’t get them out of your calculation once they are in. Due to the special numbers, floating-point numbers are not ordered. Rust takes that seriously and it’s by default not possible to sort an array of floating-points.
- There is a positive and a negative 0
- NaN is weird. It is not equal to itself.
- Basic math rules do not necessarily apply due to rounding. a+a+a is not always equal to 3*a. This means you need to be careful when comparing values and usually, you need to apply a margin or error to account for rounding. Doing this right can be tricky.
- IEEE754 has the same properties as the decimal representation. Decimal has fractions that are easily displayed with a finite number of digits like 1/10 and those that aren’t (1/3), and IEEE754 has them as well - they are just not the same. This is due to one being based on a decimal number system, the other on a binary one.
More details in the great Floating Point Guide
Enums
Color mentioned above is an enum. Enums are cousins to structs. They are a way to define new data types. Whereas each field in a struct is available all the time, with enums only one field can be active at any given time. To make the difference more clear - envision all possible values that a struct can take and count them. The number of combinations is the product of the number of possible values for each field. For an enum, the number of combinations is the sum of all possible values for each option. Structs are product types, enums are sum types (thx to Ben Dean for his great talk “Using Types Effectively” on the topic).
Bevy uses the Enum to represent the different ways colors can be specified.
Movement
Now that we can display the player, we need to be able to move it. This requires additional things:
- code that runs regularly (e.g. every frame) to update the picture.
- code that reacts on inputs like the keyboard.
Scheduler
A scheduler is a piece of software that runs code at a given time. In your OS, the scheduler distributes the CPU time to your different programs that are running (e.g. VS Code, your browser, music player, …). Inside a process (roughly what you see as entries in your task manager), the computing time is distributed between different threads. Distributing work over multiple threads became important in the early 2000s with the advent of multi-core processors. Modern PC hardware always has multiple cores - my 7800X3D for example has 8 different cores. There’s also the concept of logical cores - but for us right now, that’s just a concept that CPU vendors implemented to use their hardware more efficiently. Important for us - Bevy takes care of utilizing multiple threads using its Schedules. We tell Bevy when certain systems need to be run and Bevy takes care of distributing them across multiple cores where possible. Possible Schedules are documented in the App documentation.
Moving the Player
According to ECS, we need a new system that is scheduled regularly. Bevy offers a schedule that runs with every frame called Update.
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(Update, move_player) //added code
.run();
}
The call to .add_systems(Update, move_player) schedules the system move_player to be run with the Update schedule - every frame. Now we need a system called move_player. As we know, systems are Rust functions with a specific signature - depending on what you want to do.
fn move_player(time: Res<Time>, mut query: Query<&mut Transform, With<Player>>) {
for mut transform in query.iter_mut() {
let speed = 100.0;
let delta = time.delta_secs();
let direction = Vec3::new(1.0, 0.0, 0.0); // Move right
transform.translation += direction * speed * delta;
}
}
There is a lot to unpack here.
- a function called
move_player. It has parameters calledtimeandquery.timeis constant and can’t be modified,queryis not. This information is important for Bevy because it uses it for parallelization (systems that modify the same component can’t run in parallel). The system has no return value because it misses the->after the parameters. time: Res<Time>: a parameter that references the Bevy resourceTime. Resources represent globally unique data in your application like time or textures.mut query: Query<&mut Transform, With<Player>>: this is the matching magic for your system. The signature tells Bevy that the system runs on all entities with aTransformcomponent that also have thePlayercomponent. The first element in angle brackets (<>) is the data that is queried.&mut Transformindicates that we want to modify the component. If themutwas missing, it would be animmutablequery.With<Player>is a filter that ensures that we’re only looking at entities that have thePlayercomponent. There are many other possibilities of filters like Changed and filters can be combined with operators like Or.<>: The parameters contain many angle brackets. These indicate generic data types. In our case,ResandQueryare generic data types i.e., they work together with multiple other data types. Let’s have a look atVec.Vecrepresents an array of data i.e. the data is indexable by element number and it is placed in memory one after another without space in between. Think of it like a row of boxes, each containing an item of the same type. How large the box needs to be depends on what’s inside. That’s where generic programming kicks in.Vecdefines the behavior independent of the actual data type but the implementation details (like how large the box needs to be aka how much memory is required) are derived from the data type of the content.Vec<f64>behaves similarly to aVec<i32>, but is different in the details.for ... in: again, a loop that iterates over things one after another. In this case, it’s not numbers.query.iter_mut()is a function call on the query and it returns the list of components matched by the query. The_mutindicates that the items can be modified.mut transformgives the current item in the iteration the nametransform. Again, it is marked as mutable.let speed = 100.0;: creates a constant with namespeedand the value100.0and auto-deduces the type tof32. If you have a good development environment, it will show you the deduced type. Note thatf64is actually Rust’s default type for floating-point literals -speedis inferred asf32here only because it’s later multiplied withdelta(anf32) and aVec3(which is built fromf32components). Removing the.0will change the deduced type toi32.let delta = time.delta_secs();: creates a constant with namedelta.delta_secs()returns the number of seconds asf32since the lastUpdate.let direction = Vec3::new(1.0, 0.0, 0.0);: Creates a new vector that represents the movement direction.direction * speed * delta: Mathematically, this is a vector multiplied with two scalar values (scalar multiplication).speedis in pixel / s. Multiplying it withdelta(unit: seconds) gives the number of pixels the player moves. That number is then used to scale up the movement.+=:x += yis a shorthand notation forx = x + y.transform.translation +=: using vector addition this modifies the translation of the entity by adding the calculated movement vector.
Short summary: the new system works only on entities that have a Transform component and a Player component. It modifies the Transform component by a movement vector. The size of the movement vector is calculated from a speed in pixel/s and the duration since the last Update.
Math Interlude: Movement Vector
In the previous section, we calculated a movement vector that is then added to the current translation to move the entity. The actual speed of the movement is the length of the vector. If you draw the vector onto a paper grid, it is the actual length of the arrow. If you analyze the calculation above closely, you’ll notice a small potential bug. It only works because our direction vector has a length of 1. Taking a different vector e.g., Vec3::new(1.0, 1.0, 0.0) will result in a faster movement.
Bevy has a function associated to Vec3 to help with that: Vec3::normalize(). Using this function, you can easily create the correct movement vector by first creating a vector to the intended target and then normalizing it before using it in the movement calculation. The length can also be calculated using Vec3::length(). If the calculation needs to be fast and you can live with the squared result, use Vec3::length_squared() instead. It avoids the costly square root calculation.
Keyboard
Controlling the Player
Now change move_player to look like this:
fn move_player(input: Res<ButtonInput<KeyCode>>, time: Res<Time>, mut query: Query<&mut Transform, With<Player>>) {
for mut transform in query.iter_mut() {
let speed = 100.0;
let delta = time.delta_secs();
//let direction = Vec3::new(1.0, 0.0, 0.0); // Move right
let mut direction = Vec3::ZERO;
if input.pressed(KeyCode::KeyW) {
direction += Vec3::new(0.0, 1.0, 0.0); // Move up
}
if input.pressed(KeyCode::KeyS) {
direction += Vec3::new(0.0, -1.0, 0.0); // Move down
}
if input.pressed(KeyCode::KeyA) {
direction += Vec3::new(-1.0, 0.0, 0.0); // Move left
}
if input.pressed(KeyCode::KeyD) {
direction += Vec3::new(1.0, 0.0, 0.0); // Move right
}
transform.translation += direction.normalize_or_zero() * speed * delta;
}
}
The changes are:
- a new
Resourcecalledinput. It is aButtonInputresource.ButtonInputis generic - here it’s parameterized withKeyCodefor keyboard buttons, but it can also be used withGamepadButtonto detect gamepad buttons. if ... {}: a conditional. It indicates that the code in{}will only be executed if the condition...is true. Condition is an expression that returns a boolean value (a value that can betrueorfalse). Please note - in Rustifconditionals are also expressions and produce a value. In other languages like C++ or Java, they are statements and do not produce a value. Rust’s approach to this can produce very clean, easy to read code once you are used to this. In our case here, we don’t use this property ofif.input.pressed(KeyCode::...): a method that checks whether one or multiple buttons are pressed at the point of calling. In our case, we only check for one key on the keyboard. The function returns abooland can thus be directly used as condition in the if-expression.- The bodies of the
if-expressionsadd a direction vector to the initial vectorVec3::ZERO. This follows the normal vector addition mentioned above. Since all vectors that can be added have the same length, opposing vectors cancel out during the addition (e.g. up+down = 0). .normalize_or_zero(): This normalizes the direction if possible (i.e. it does not have a length of 0) or returns the 0-vector otherwise. This ensures that diagonal movement is at the same speed as movement along one of the axes (e.g. up).
And with this, we can move our @ sign around.
Summary
After a first short introduction to the main architectural pattern of Bevy - the Entity Component System - we created our first component. A tag component to designate the player. Then we attached multiple other components to it via a tuple to display an @ sign. We brushed the topics of floating-point representation of numbers in Rust as well as enums, a sum type compared to struct, a product type.
Then we started moving the player. Using Bevy schedules, we introduced operations that run every frame and updated the Transform component of our player to move. This gave us a glance at vectors in math and we used them to describe the movement. We adapted the speed according to the frame-rate using the Time resource.
After we knew how to move our player, we hooked it up to the keyboard using another Bevy resource - ButtonInput. Using our knowledge of vector math and Rust conditional expressions (if), we changed the movement vector according to the keys pressed. Then we normalized it to make up for even speed in all directions.