with Cheikh Seck
Introduction
This is the first in a series of posts that will explore the Rust programming language. I am going to take the same approach I did with Go and write little programs that explore the different features of the language. Before I begin that work, I thought it would be fun to write a program that does something fun.
In this post and my first ever Rust program, I’m going to use a Rust library called bracket-lib that provides a cross-platform terminal emulator. I will use this library to build a simple game window that will allow me to animate a box that can be moved up, with gravity causing it to move back down.
All the code in this post can be found on Github at this link.
Installation
To start, you need the Rust front end tool called cargo
. If you are new to Rust and don’t have it installed yet, you can follow these instructions.
If you need help with your editor environment there are plugins for the different editors.
For VSCode these are two plugins that I am using:
https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer
https://marketplace.visualstudio.com/items?itemName=statiolake.vscode-rustfmt
For any of the JetBrains products, you have these options:
IDE IntelliJ: https://www.jetbrains.com/rust/
JetBrains: https://plugins.jetbrains.com/plugin/8182-rust
Create a New Rust Project
Before you can do anything, you need to initialize a new Rust program.
Listing 1:
$ cargo new simple-game
In listing 1, you can see the use of cargo to initialize a new Rust program called simple-game
. This will result in the creation of a few files.
Listing 2:
.
├── Cargo.lock
├── Cargo.toml
├── src
│ ├── main.rs
Listing 2 shows the files that should be created after running the cargo command.
Note: If you’re not in a git repo when you run “cargo new”, the command also gives you a .gitignore file and creates a repo for you. (Unless you specify “–vcs none”)
Now the bracket-lib dependency needs to be added to the project.
Listing 3:
$ cargo add bracket-lib
Updating crates.io index
Adding bracket-lib ~0.8 to dependencies.
Features as of v0.8.0:
+ opengl
- amethyst_engine_metal
- amethyst_engine_vulkan
- crossterm
- curses
- serde
- specs
- threaded
Listing 3 shows how to use the cargo add
command to search the crate list for bracket-lib and insert the dependency into the project.
Listing 4:
01 [package]
02 name = "simple-game"
03 version = "0.1.0"
04 edition = "2021"
05
06 # See more keys and their definitions at https://doc.rust-lang.org/cargo
07
08 [dependencies]
09 bracket-lib = "~0.8"
In listing 4, you can see the cargo.toml
file and the entry added to line 09 by the cargo add command. This line will add bracket-lib as a dependency for the program.
Main Function
With the Rust program initialized and the bracket-lib dependency configured, writing the code for the game can begin.
Open the main.rs
file found in the src/
folder.
Listing 5:
01 use bracket_lib::terminal::{main_loop, BTermBuilder};
02 mod state;
03
04 fn main() {
05 let result = BTermBuilder::simple80x50()
06 .with_title("Hello Bracket World")
07 .build();
08
09 match result {
10 Ok(bterm) => {
11 let _ = main_loop(bterm, state::new());
12 }
13 Err(err) => {
14 println!("An error has occurred: {err:?}");
15 println!("Aborting");
16 }
17 }
18 }
Listing 5 shows all the code needed for the main function. On line 01, two identifiers from the bracket-lib library are imported into the global namespace. Then on line 02, the state
module (which we have not written yet) is made accessible to this source code file. The state
module will live inside the project and will contain all of the functionality required by the game.
Note: By selecting the exact identifiers you need in a use
statement, you can help compile times a bit (reducing the number of identifiers to lookup). It can also help prevent collisions with identifiers being named the same in different dependencies. However, this is mostly done to keep things tidy.
On line 05, the code builds a game window using the simple80x50
function from the bracket-lib dependency. The call to the build
function on line 08 returns a result value and the code on lines 09 through 17 checks the result for success or failure.
The best way to explain how the error handling works in Rust is to look at this new example.
Listing 6:
01 struct User {
02 name: String,
03 age: isize,
04 }
05
06 struct Error {
07 msg: String,
08 }
09
10 fn query_user(worked: bool) -> Result<User, Error> {
11 if worked {
12 return Ok(User {
13 name: "Bill".to_string(),
14 age: 53,
15 });
16 }
17
18 return Err(Error {
19 msg: "unable to fetch user".to_string(),
20 });
21 }
22
23 fn main() {
24 match query_user(true) {
25 Ok(usr) => println!("Name:{0} Age:{1}", usr.name, usr.age),
26 Err(err) => println!("{0}", err.msg),
27 }
28
29 match query_user(false) {
30 Ok(usr) => println!("Name:{0} Age:{1}", usr.name, usr.age),
31 Err(err) => println!("{0}", err.msg),
32 }
33
Listing 6 shows a new program that can best explain how error handling works in Rust. On lines 01 through 08, two structs are declared. The User
struct represents a user in the system and the Error
struct represents a general error type. On lines 10 through 21, a function named query_user
is declared that accepts a boolean value and returns a value of type Result
. This type is provided by the standard library and is actually an enumeration.
Note: I had two Rust developers tell me it’s more idiomatic in Rust to use an if statement for a boolean check. I avoid else statements at all costs, and use switch statements in Go and Javascript for the same situation. I believe writing conditional logic without an else is more readable.
Listing 7:
enum Result<T, E> {
Ok(T),
Err(E),
}
In listing 7 you can see how Result
is declared and how a value of type Result
can either be Ok
or Err
. In either case, a value of some type T
or E
can be passed respectively.
Listing 8:
10 fn query_user(worked: bool) -> Result<User, Error> {
11 if worked {
12 return Ok(User {
13 name: "Bill".to_string(),
14 age: 53,
15 });
16 }
17
18 return Err(Error {
19 msg: "unable to fetch user".to_string(),
20 });
21 }
In listing 8 you see the query_user
function again. On line 11, an if statement is used to check if the worked
parameter is true. If the variable is true, then the result
variable is assigned a value of Ok
with a value of type User
. If the worked
variable is false, then the result
variable is assigned a value of Err
with a value of type Error
.
Listing 9:
23 fn main() {
24 match query_user(true) {
25 Ok(usr) => println!("Name:{0} Age:{1}", usr.name, usr.age),
26 Err(err) => println!("{0}", err.msg),
27 }
28
29 match query_user(false) {
30 Ok(usr) => println!("Name:{0} Age:{1}", usr.name, usr.age),
31 Err(err) => println!("{0}", err.msg),
32 }
33 }
Finally in listing 9, calls to the query_user
function are made on lines 24 and 29. A match
statement is used once more to compare which value, either Ok
or Err
, was returned by the query_user
function. Depending on the returned value, a user value or error is printed.
Now back to the original program.
Listing 10:
01 use bracket_lib::terminal::{main_loop, BTermBuilder};
02 mod state;
03
04 fn main() {
05 let result = BTermBuilder::simple80x50()
06 .with_title("Hello Bracket World")
07 .build();
08
09 match result {
10 Ok(bterm) => {
11 let _ = main_loop(bterm, state::new());
12 }
13 Err(err) => {
14 println!("An error has occurred: {err:?}");
15 println!("Aborting");
16 }
17 }
18 }
In the case of the original program, the result from the call to build
is handled with a match
statement on line 09. Like the previous sample program, the values of Ok
and Err
are compared. In this case the build
function is returning a Result
value declared like this.
Listing 11:
Result<BTerm, Box<dyn Error + Send + Sync>>
Listing 11 shows a BTerm
value is provided for the Ok
case and a Box
value is provided for the Err
case. Now depending on what is matched, the main function either runs the main_loop
function from the bracket-lib library or prints the error information.
State Module
Listing 12:
.
├── Cargo.lock
├── Cargo.toml
├── src
│ ├── main.rs
│ └── state
│ └── mod.rs
Listing 12 shows how a new folder named state/
is added to the src/
folder and then a file named mod.rs
is created. When creating a new module, the core source code file needs to be named mod.rs
so the compiler can recognize this folder as a module and the pub
keyword (that we used in the main function) can be used to make the module accessible when needed.
Listing 13:
01 //! State contains all the game state and logic.
Listing 13 shows the first line of code in the mod.rs
source code file. The program starts out with a comment using //!
, which is used to indicate this comment needs to be part of the module’s documentation.
If you want to see the program’s documentation, you can run the cargo doc command.
Listing 14:
$ cargo doc --no-deps --open
Then the following webpage should open in your default browser.
Figure 1
If you want to learn more about writing documentation, this post provides nice detailed examples.
Listing 15:
03 use bracket_lib::prelude::*;
04
05 const GAME_WINDOW_HEIGHT: isize = 50;
06 const GAME_WINDOW_WIDTH: isize = 80;
07 const BOX_HEIGHT: isize = 5;
08 const BOX_WIDTH: isize = 5;
09 const CEILING_POS: isize = 5;
10 const FLOOR_POS: isize = 45;
11 const CEILING_COLLISION: isize = CEILING_POS + 1;
12 const FLOOR_COLLISION: isize = FLOOR_POS - BOX_HEIGHT - 1;
In listing 15, the code imports the bracket-lib library again and then defines 8 constants that represent different hard coded values for the game. The idiom for constants is to use capital letters and underscores. You can see the type information follows the identifier’s name, just like in Go. Rust has all the same precision based integer types, but defines isize
and usize
as the architecture-dependent integer size, which match the size of an address.
Unfortunately, the Rust format program does not align types and assignments like the Go format tooling does. :(
Listing 16:
14 /// Moving represents the set of possible moving options.
15 enum Moving {
16 Not,
17 Up,
18 Down,
19 }
The next section of code in listing 16 defines an enumeration. The comment with the three slashes indicates this comment should be part of the module’s documentation for this identifier. The three values declared for this enumeration will represent a direction the game block could be moving. The idiom for naming an enumeration is to use CamelCase.
Listing 17:
23 /// State represents the game state for the game.
24 pub struct State {
25 box_y: isize, // Box's vertical position.
26 box_moving: Moving, // Direction the box is moving.
27 }
In listing 17, a struct type is declared named State
. The idiom for naming a struct type is to use CamelCase like it was with the enumeration, however the idiom for naming a field name is to use snake_case.
The struct type is marked as public using the pub
access specifier. This will allow other modules (like main) to have access to values of this type. However, the two fields that are declared on lines 25 and 26 are not marked as pub
and will remain private and only accessible to code inside the state
module.
Note: The three slash comments do not work if used on the side of a field declaration like you see in listing 17.
Listing 18:
29 /// new constructs a new game state.
30 pub fn new() -> State {
31 return State {
32 box_y: FLOOR_COLLISION,
33 box_moving: Moving::Not,
34 };
35 }
Since the construction of a State
value requires specific initialization, a factory function is declared with the name new
in listing 18. The idiom for function names is to use snake_case like it was with field names. The syntax for a function declaration is fairly obvious except for the use of an arrow operator to define the return argument. Functions in Rust can return multiple values using tuples.
Traits
Listing 19:
37 /// State implementation of the GameState trait.
38 impl GameState for State {
39 fn tick(&mut self, bterm: &mut BTerm) {
40 self.keyboard_input(bterm);
41 self.render(bterm);
42 }
43 }
In listing 19, you can see a declaration for the State
type to implement the GameState
trait declared by bracket-lib.
Note: Traits are similar to a feature often called interfaces in other languages, although with some differences.
The bracket-lib library is going to provide the GUI framework for the game and requires the implementation of a function named tick
. It’s this tick
function that bracket-lib will call every time it wants to render the screen. The GameState
trait acts like an interface and declares the required behavior the library needs to have implemented.
On line 39, you see the declaration of the tick
function needed to satisfy the trait. This is the only function declared by the GameState
trait.
Listing 20:
01 pub use crate::prelude::BTerm;
02
03 /// Implement this trait on your state struct, so the engine knows what...
04 pub trait GameState: 'static {
05 fn tick(&mut self, ctx: &mut BTerm);
06 }
Listing 20 shows the declaration of the GameState
trait provided by bracket-lib. You can see there is only one function named tick
that has two parameters. The first parameter named self
is the receiver and will contain a pointer to the value implementing the trait. The second parameter is named ctx
and will contain a pointer to a value that represents the game window. The first parameter must be named self
and requires no type declaration.
It’s the use of the &
operator that declares these parameters are using pointer semantics. The &
operator is often called a “borrow” in Rust. The use of the keyword mut
means the variable can be used to mutate the value. If you leave out mut
, then the variable is read only.
Note: Another useful way to refer to &mut
is to call it an “exclusive” reference. Only one entity can have access to an exclusive reference at a time in Rust, and this is guaranteed by the compiler. This is one of the things that makes Rust safe.
Listing 21:
37 /// State implementation of the GameState trait.
38 impl GameState for State {
39 fn tick(&mut self, bterm: &mut BTerm) {
40 self.keyboard_input(bterm);
41 self.render(bterm);
42 }
43 }
If you notice on line 39 in listing 21, I changed the name of the ctx
parameter to bterm
in my function declaration. I am doing this to be more specific with what that value represents.
The implementation of the tick
function performs two method calls against the State
value that is represented by self
. The first method is named keyboard_input
and it checks to see if the space bar was pressed. The second method is named render
and it renders the screen with any changes that were made to the game state.
Listing 22:
45 /// Method set for the State type.
46 impl State {
47 /// keyboard_input handles the processing of keyboard input.
48 fn keyboard_input(&mut self, bterm: &mut BTerm) {
58 }
59
60 /// render takes the current game state and renders the screen.
61 fn render(&mut self, bterm: &mut BTerm) {
133 }
134 }
On line 46 in listing 22, the keyword impl
is used to implement a method-set of functions for the State
type. You can see the declaration of the two methods that were called in listing 21 by the tick
function. Once again, the first parameter is a pointer to the State
value that was constructed when the game started and the second parameter is a pointer to the game window.
Listing 23:
47 /// keyboard_input handles the processing of keyboard input.
48 fn keyboard_input(&mut self, bterm: &mut BTerm) {
49 match bterm.key {
50 None => {}
51 Some(VirtualKeyCode::Space) => {
52 if self.box_y == FLOOR_COLLISION {
53 self.box_moving = Moving::Up;
54 }
55 }
56 _ => {}
57 };
58 }
Listing 23 shows the implementation of the keyboard_input
function. The function uses a match
statement to compare the key
field from the bterm
value to see if the spacebar has been hit. The key
field is an enumeration of type Option
and can represent one of two values, None
or Some
.
If a None
value is returned, then there is no pending user input. If a Some
value is returned and the key that was pressed was the spacebar, that case is executed. The Some
value has been decalred to support comparing an enumeration value of type VirtualKeyCode
. The blank identifier case on line 56 is the default case and is required in this match
because there are 159 other declarations of Some
accepting different types that could be used.
The code in the Some
case checks to see if the box is on the ground and changes the box_moving
field to moving up if that’s true.
Listing 24:
60 /// render takes the current game state and renders the screen.
61 fn render(&mut self, bterm: &mut blib::BTerm) {
62 bterm.cls_bg(WHITE);
Listing 24 shows the implementation of the render
function. The code on line 62 clears the entire game window by painting it white.
Listing 25:
64 bterm.draw_bar_horizontal(
65 0, // x
66 CEILING_POS, // y
67 GAME_WINDOW_WIDTH, // width
68 GAME_WINDOW_HEIGHT, // n
69 GAME_WINDOW_HEIGHT, // max
70 YELLOW, // foreground color
71 YELLOW, // background color
72 );
73
74 bterm.draw_bar_horizontal(
75 0, // x
76 GROUND_PIXEL, // y
77 GAME_WINDOW_WIDTH, // width
78 GAME_WINDOW_HEIGHT, // n
79 GAME_WINDOW_HEIGHT, // max
80 YELLOW, // foreground color
81 YELLOW, // background color
82 );
The code in listing 25 draws two yellow lines that represent the ceiling and the floor.
Listing 26:
84 bterm.draw_box_double(
85 (GAME_WINDOW_WIDTH / 2) - 3, // x
86 self.box_y, // y
87 BOX_WIDTH, // width
88 BOX_WIDTH, // height
89 RED, // foreground color
90 RED, // background color
91 );
The code in listing 26 draws the game box that the player will maneuver. The box is centered in the middle of the game window.
Listing 27:
93 match self.box_moving {
94 Moving::Down => {
95 self.box_y += 1;
96 if self.box_y == FLOOR_COLLISION {
97 self.box_moving = Moving::Not;
98 }
99 }
100 Moving::Up => {
101 self.box_y -= 1;
102 if self.box_y == CEILING_COLLISION {
103 self.box_moving = Moving::Down;
104 }
105 }
106 Moving::Not => {}
107 }
Listing 27 shows the final code for the program. This code checks the position of the game box and changes its position depending on the direction it’s moving. If the game box hits the ground, the box is set to stop moving. If the game box hits the ceiling, the box is set to fall back down.
Running The Game
To launch the game, run cargo run
at the root of the project’s folder. Any dependencies that are missing will be automatically downloaded into a folder named target
.
Listing 28:
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.05s
Running `target/debug/simple-game`
Initialized OpenGL with: 4.1 Metal - 83, Shader Language Version: 4.10
Figure 2
Figure 2 shows you the game window and the red box. Remember to use the spacebar to cause the game box to move up towards the ceiling. Then watch the box automatically fall back down.
Note: The executable (statically linked, including all dependencies) will be in target/debug
(name varies by platform; e.g. on this Windows box it’s a .exe
). Make sure you have the target folder in your .gitignore
You don’t want to be committing your build artifacts.
Conclusion
This was my first post in a series where I will begin to explore the Rust programming language. This was a fun program to start with since it allowed me to begin to understand how types, assignments, idioms, method-sets, error handling, and traits work. These are all things I want to explore in more detail. Hopefully you were able to run the program and begin to understand Rust syntax.