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.

Trusted by top technology companies

We've built our reputation as educators and bring that mentality to every project. When you partner with us, your team will learn best practices and grow along the way.

30,000+

Engineers Trained

1,000+

Companies Worldwide

12+

Years in Business