- {{node.title}}
{{node.type}} · {{ node.urlSource.name }} · by {{node.authors[0].realName }}
Basics of Ownership: Rust Crash Course Lesson 2, Part 2
Basics of Ownership: Rust Crash Course Lesson 2, Part 2
In this post, a software developer walks us through how to develop a browser-based game using Rust. Read on to become a Rustacean!
Nov. 01, 18 · Web Dev Zone ·
Comment (0)
Join the DZone community and get the full member experience.
Bugsnag monitors application stability, so you can make data-driven decisions on whether you should be building new features, or fixing bugs. Learn more.
Welcome back! If you missed Part 1 of this lesson, you can check it out here.
Bouncy
Enough talk, let’s fight! I want to create a simulation of a bouncing ball.
Let’s step through the process of creating such a game together. I’ll provide the complete src/main.rs
at the end of the lesson, but strongly recommend you implement this together with me throughout the sections below. Try to avoid copy-pasting, but instead type in the code yourself to get more comfortable with Rust syntax.
Initialize the Project
This part’s easy:
$ cargo new bouncy
If you cd
into that directory and run cargo run
, you’ll get output like this:
$ cargo run Compiling bouncy v0.1.0 (/Users/michael/Desktop/bouncy) Finished dev [unoptimized + debuginfo] target(s) in 1.37s Running `target/debug/bouncy` Hello, world!
The only file we’re going to touch today is src/main.rs
, which will have the source code for our program.
Define Data Structures
To track the ball bouncing around our screen, we need to know the following information:
- The width of the box containing the ball.
- The height of the box containing the ball.
- The x and y coordinates of the ball.
- The vertical direction of the ball (up or down).
- The horizontal direction of the ball (left or right).
We’re going to define new datatypes for tracking the vertical and horizontal direction, and use u32
s for tracking the position.
We can define VertDir
as an enum
. This is a simplified version of what enums can handle since we aren’t giving it any payload. We’ll do more sophisticated stuff later.
enum VertDir { Up, Down, }
Go ahead and define a HorizDir
as well; this tracks whether we’re moving left or right. Now, to track a ball, we need to know its x
and y
positions and its vertical and horizontal directions. This will be a struct
since we’re tracking multiple values instead of (like an enum) choosing between different options.
struct Ball { x: u32, y: u32, vert_dir: VertDir, horiz_dir: HorizDir, }
Define a Frame
struct that tracks the width and height of the play area. Then tie it all together with a Game
struct:
struct Game { frame: Frame, ball: Ball, }
Create a New Game
We can define a method on the Game
type itself to create a new game. We’ll assign some default width and height and initial ball position.
impl Game { fn new() -> Game { let frame = Frame { width: 60, height: 30, }; let ball = Ball { x: 2, y: 4, vert_dir: VertDir::Up, horiz_dir: HorizDir::Left, }; Game {frame, ball} } }
Challenge: Rewrite this implementation to not use any let
statements.
Notice how we use VertDir::Up
; the Up
constructor is not imported into the current namespace by default. Also, we can define Game
with frame, ball
instead of frame: frame, ball: ball
since the local variable names are the same as the field names.
Bounce
Let’s implement the logic of a ball to bounce off of a wall. Let’s write out the logic:
- If the
x
value is 0, we’re at the left of the frame, and therefore we should move right. - If
y
is 0, move down. - If
x
is one less than the width of the frame, we should move left. - If
y
is one less than the height of the frame, we should move up. - Otherwise, we should keep moving in the same direction.
We’ll want to modify the ball, and take the frame as a parameter. We’ll implement this as a method on the Ball
type.
impl Ball { fn bounce(&mut self, frame: &Frame) { if self.x == 0 { self.horiz_dir = HorizDir::Right; } else if self.x == frame.width - 1 { self.horiz_dir = HorizDir::Left; } ...
Go ahead and implement the rest of this function.
Move
Once we know which direction to move in by calling bounce
, we can move the ball one position. We’ll add this as another method within impl Ball
:
fn mv(&mut self) { match self.horiz_dir { HorizDir::Left => self.x -= 1, HorizDir::Right => self.x += 1, } ... }
Implement the vertical half of this as well.
Step
We need to add a method to Game
to perform a step of the game. This will involve both bouncing and moving. This goes inside impl Game
:
fn step(&self) { self.ball.bounce(self.frame); self.ball.mv(); }
There are a few bugs in that implementation which you’ll need to fix.
Render
We need to be able to display the full state of the game. We’ll see that this initial implementation has its flaws, but we’re going to do this by printing the entire grid. We’ll add a border, use the letter o
to represent the ball, and put spaces for all of the other areas inside the frame. We’ll use the Display
trait for this.
Let’s pull some of the types into our namespace. At the top of our source file, add:
use std::fmt::{Display, Formatter};
Now, let’s make sure we got the type signature correct:
$ cargo run Compiling bouncy v0.1.0 (/Users/michael/Desktop/bouncy) Finished dev [unoptimized + debuginfo] target(s) in 1.37s Running `target/debug/bouncy` Hello, world!
0
We can use the unimplemented!()
macro to stub out our function before we implement it. Finally, let’s fill in a dummy main
function that will print the initial game:
$ cargo run Compiling bouncy v0.1.0 (/Users/michael/Desktop/bouncy) Finished dev [unoptimized + debuginfo] target(s) in 1.37s Running `target/debug/bouncy` Hello, world!
1
If everything is set up correctly, running cargo run
will result in a “not yet implemented” panic. If you get a compilation error, go fix it now.
Top Border
Alright, now we can implement fmt
. First, let’s just draw the top border. This will be a plus sign, a series of dashes (based on the width of the frame), another plus sign, and a newline. We’ll use the write!
macro, range syntax (low..high
), and a for
loop:
$ cargo run Compiling bouncy v0.1.0 (/Users/michael/Desktop/bouncy) Finished dev [unoptimized + debuginfo] target(s) in 1.37s Running `target/debug/bouncy` Hello, world!
2
Looks nice, but we get a compilation error:
$ cargo run Compiling bouncy v0.1.0 (/Users/michael/Desktop/bouncy) Finished dev [unoptimized + debuginfo] target(s) in 1.37s Running `target/debug/bouncy` Hello, world!
3
It says “considering removing this semicolon.” Remember that putting the semicolon forces our statement to evaluate to the unit value ()
, but we want a Result
value. And it seems like the write!
macro is giving us a Result
value. Sure enough, if we drop the trailing semicolon, we get something that works:
$ cargo run Compiling bouncy v0.1.0 (/Users/michael/Desktop/bouncy) Finished dev [unoptimized + debuginfo] target(s) in 1.37s Running `target/debug/bouncy` Hello, world!
4
You may ask: what about all of the other Result
values from the other calls to write!
? Good question! We’ll get to that a bit later.
Bottom Border
The top and bottom border are exactly the same. Instead of duplicating the code, let’s define a closure that we can call twice. We introduce a closure in Rust with the syntax |args| { body }
. This closure will take no arguments, and so will look like this:
$ cargo run Compiling bouncy v0.1.0 (/Users/michael/Desktop/bouncy) Finished dev [unoptimized + debuginfo] target(s) in 1.37s Running `target/debug/bouncy` Hello, world!
5
First, we’re going to get an error about Result
and ()
again. You’ll need to remove two semicolons to fix this. Do that now. Once you’re done with that, you’ll get a brand new error message. Yay!
$ cargo run Compiling bouncy v0.1.0 (/Users/michael/Desktop/bouncy) Finished dev [unoptimized + debuginfo] target(s) in 1.37s Running `target/debug/bouncy` Hello, world!
6
The error message tells us exactly what to do: stick a mut
in the middle of let top_bottom
. Do that, and make sure it fixes things. Now the question: why? The top_bottom
closure has captured the fmt
variable from the environment. In order to use that, we need to call the write!
macro, which mutates that fmt
variable. Therefore, each call to top_bottom
is itself a mutation. Therefore, we need to mark top_bottom
as mutable.
There are three different types of closure traits: Fn
, FnOnce
, and FnMut
. We’ll get into the differences among these in a later tutorial.
Anyway, we should now have both a top and bottom border in our output.
Rows
Let’s print each of the rows. In between the two top_bottom()
calls, we’ll stick a for
loop:
$ cargo run Compiling bouncy v0.1.0 (/Users/michael/Desktop/bouncy) Finished dev [unoptimized + debuginfo] target(s) in 1.37s Running `target/debug/bouncy` Hello, world!
7
Inside that loop, we’ll want to add the left border and the right border:
$ cargo run Compiling bouncy v0.1.0 (/Users/michael/Desktop/bouncy) Finished dev [unoptimized + debuginfo] target(s) in 1.37s Running `target/debug/bouncy` Hello, world!
8
Go ahead and call cargo run
, you’re in for an unpleasant surprise:
$ cargo run Compiling bouncy v0.1.0 (/Users/michael/Desktop/bouncy) Finished dev [unoptimized + debuginfo] target(s) in 1.37s Running `target/debug/bouncy` Hello, world!
9
Oh no, we’re going to have to deal with the borrow checker!
Fighting the Borrow Checker
Alright, remember before that the top_bottom
closure capture a mutable reference to fmt
? Well, that’s causing us some trouble now. There can only be one mutable reference in play at a time and top_bottom
is holding it for the entire body of our method. Here’s a simple workaround in this case: take fmt
as a parameter to the closure, instead of capturing it:
enum VertDir { Up, Down, }
0
Go ahead and fix the calls to top_bottom
, and you should get output that looks like this (some extra rows removed).
enum VertDir { Up, Down, }
1
Alright, now we can get back to…
Columns
Remember that // more code will go here
comment? Time to replace it! We’re going to use another for
loop for each column:
enum VertDir { Up, Down, }
2
Running cargo run
will give you a complete frame, nice! Unfortunately, it doesn’t include our ball. We want to write a o
character instead of space when column
is the same as the ball’s x
, and the same thing for y
. Here’s a partial implementation:
enum VertDir { Up, Down, }
3
There’s something wrong with the output (test with cargo run
). Fix it and your render function will be complete!
The Infinite Loop
We’re almost done! We need to add an infinite loop in our main
function that:
- Prints the game.
- Steps the game.
- Sleeps for a bit of time.
We’ll target 30 FPS, so we want to sleep for 33ms. But how do we sleep in Rust? To figure that out, let’s go to the Rust standard library docs and search for sleep
. The first result is std::thread::sleep
, which seems like a good bet. Check out the docs there, especially the wonderful example, to understand this code.
enum VertDir { Up, Down, }
4
There’s one compile error in this code. Try to anticipate what it is. If you can’t figure it out, ask the compiler, then fix it. You should get a successful cargo run
that shows you a bouncing ball.
Problems
There are two problems I care about in this implementation:
- The output can be a bit jittery, especially on a slow terminal. We should really be using something like the
curses
library to handle double buffering of the output. - If you ran
cargo run
before, you probably didn’t see it. Runcargo clean
andcargo build
to force a rebuild, and you should see the following warning:
enum VertDir { Up, Down, }
5
I mentioned this problem above: we’re ignoring failures coming from the calls to the write!
macro in most cases, but throwing away the Result
using a semicolon. There’s a nice, single character solution to this problem. This forms the basis of proper error handling in Rust. However, we’ll save that for another time. For now, we’ll just ignore the warning.
Complete Source
You can find the complete source code for this implementation as a GitHub gist. Reminder: it’s much better if you step through the code above and implement it yourself.
I’ve added one piece of syntax we haven’t covered yet in that tutorial, at the end of the call to top_bottom
. We’ll cover that in much more detail next week.
Monitor application stability with Bugsnag to decide if your engineering team should be building new features on your roadmap or fixing bugs to stabilize your application.Try it free.
Like This Article? Read More From DZone
Comment (0)
Published at DZone with permission of Michael Snoyman , DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Web Dev Partner Resources
Web Dev Partner Resources
- {{ node.blurb }}
{{ editionName }}
{{ parent.title || parent.header.title}}
{{ parent.tldr }}
{{ parent.linkDescription }}
{{ $dialog.title }}
{{ message }}