Tutorial: Pong game in Rust ๐Ÿฆ€

Eleftheria BatsouEleftheria Batsou
May 17, 2024ยท
11 min read

Hello, amazing people and welcome back to my blog! Today we're going to learn how to build a Pong game using the piston engine as well as the OpenGL graphics library.

In the end, we will have a board with 2 paddles, one on the left and one on the right side, and one ball. We'll also have 2 players who will be able to handle the left and the right paddles with Y and X keys and the up and down arrows.

Let's build this. ๐ŸŽพ

Dependencies

Toml File

[dependencies]
piston = "0.35.0"
piston2d-graphics = "0.24.0"
pistoncore-glutin_window = "0.43.0"
piston2d-opengl_graphics = "0.50.0"

First, we need the piston engine itself, then we'll need our piston 2D graphics and we'll need our pistoncore-glutin_window and our piston2d-opengl_graphics.

Tip: When you write the dependencies you can use inside the quotes an asterisk for the version number. Then go to the terminal, typecargo updateand this will update all of your dependencies in thecargo.lockfile. If we go to thelockfile, we can search out the libraries, then copy the number and replace the asterisk back in thetomlfile.

piston = "*"

The reason it's important to use static versions is just in case the library actually changes. If the syntax changes, then the game will not work properly anymore because we will be behind.

Main rs File

Let's go to the main.rs file and bring in all of our external libraries and make some imports!

extern crate glutin_window;
extern crate graphics;
extern crate opengl_graphics;
extern crate piston;

I will need the process the piston::window so that we can set up our window, the event_loop to set up our event settings, we'll need our piston::input for Key, PressEvent etc and then we'll also need our glutin_window (this allows us to create an OpenGL window) and opengl_graphics which contains our GlGraphics, OpenGL.

use std::process;
use piston::window::WindowSettings;
use piston::event_loop::{EventSettings, Events};
use piston::input::{Button, Key, PressEvent, ReleaseEvent, RenderArgs, RenderEvent, UpdateArgs, UpdateEvent};
use glutin_window::GlutinWindow;
use opengl_graphics::{GlGraphics, OpenGL};

Now we want to create a structure called App.

  • Inside of this, we will have a field called gl which will connect to our GlGraphics type. Then we'll have our left score as well as the left position and left velocity. This will correspond to the left paddle.

  • Then we'll have the right score, the right position, and the right velocity which will correspond to our right paddle. All these are i32 .

  • Then we'll have ball_x and ball_y and velocity_x and velocity_y. All four of these will correspond with our ball.

Tip: If you want to write a more full featured application you can split these off into their own objects.

pub struct App {
    gl: GlGraphics,
    left_score: i32,
    left_pos: i32,
    left_vel: i32,
    right_score: i32,
    right_pos: i32,
    right_vel: i32,
    ball_x: i32,
    ball_y: i32,
    vel_x: i32,
    vel_y: i32,
}

Next, we want to create an implementation block for our application. Inside of it we need a render method. This method will take in a mutable self and our arguments (which will be a reference to our render arguments).

  • We want to have an import for graphics so that we can easily get to it inside of this function.

  • We will also create some constants for the colors of our game. They will be [f32; 4]. Our background color will be the color in the background of our window. Whereas our foreground color will be the color that we paint our paddles and our ball with.

  • We're going to create a variable called left which will be a rectangle square. This takes in scalars for x and y as well as size and we're just going to make our x and y zero and then we're going to make the size 50 so that it has some width.

  • Then we're going to create a variable called left_pos and this will take our self from our struct and cast it into an f64.

  • We'll do the same for our right and right_pos.

  • Last but not least, for our ball, we also want it to be a square. We don't want it to have any specific x and y values but we want it to be a square of size 10. Then we want to do what we did for our positions for ball_x and ball_y. These are going to cast our i32 into f64 for the rendering.

impl App {
    fn render(&mut self, args: &RenderArgs) {
        use graphics::*;

        const BACKGROUND: [f32; 4] = [0.0, 0.5, 0.5, 1.0];
        const FOREGROUND: [f32; 4] = [0.0, 0.0, 1.0, 1.0];

        let left = rectangle::square(0.0, 0.0, 50.0);
        let left_pos = self.left_pos as f64;
        let right = rectangle::square(0.0, 0.0, 50.0);
        let right_pos = self.right_pos as f64;

        let ball = rectangle::square(0.0, 0.0, 10.0);
        let ball_x = self.ball_x as f64;
        let ball_y = self.ball_y as f64;
.
.
.
}

Then we want to call self.gl.draw and this is a method that's included inside of our opengl_graphicslibrary. This takes in our args.viewport and then it takes in a closure which takes in c and gl (c being the context and gl being our opengl graphic renderer.) Inside of this closure we first want to clear our board and apply the background. We run this method called clear which takes in the color that we want the background to be and then the actual renderer which is the gl.

Then we want to create a rectangle. This will be our foreground color. It will be on the left as well and it will have a transform of -40 and then our left_pos and the gl.

Our right paddle things are going to be slightly different on the transform. We're going to color it with the foreground color and we're still going to put in the right variable but we also need to transform it by args.width so the width of the actual args from the viewport as a f64 and then we're going to have -10.

Now we want to render our ball with the foreground color. Let's add c.transform.trans(ball_x, ball_y) and lastly let's add the gl as well.

To recap this part: First we are defining our paddles as squares and then we're using the transform to stretch our paddles downwards towards the bottom so that they actually are rectangles rather than squares.

self.gl.draw(args.viewport(), |c, gl| {
            clear(BACKGROUND, gl);
            rectangle(FOREGROUND, left, c.transform.trans(-40.0, left_pos), gl);
            rectangle(
                FOREGROUND,
                right,
                c.transform.trans(args.width as f64 - 10.0, right_pos),
                gl,
            );
            rectangle(FOREGROUND, ball, c.transform.trans(ball_x, ball_y), gl);
        });

The update method

Now we need to create an update method. Our update method will be where all of our game logic lies. We're taking in a mutable self so a mutable version of our struct and then we're going to take in our UpdateArgs which are sort of like our render args except made for updating.

Now we're going to write a few if statements, buckle up! ๐Ÿ™‚

Our first two if statements check to see if our paddles are about to go off the screen.

In the first if statement we're checking to see if our left paddle's velocity is == 1 and if self.left_pos < 291. In other words it's going off the bottom of the screen or we're checking to see if the left velocity == -1 and && self.left_pos >= 1 , in other words it's going off the top of the screen. If that happens then we want to increment the actual paddle so that it comes back onto the screen. So as soon as the paddle goes off the screen just slightly it will be incremented back onto the screen.

We do the same for the right paddle.

fn update(&mut self, _args: &UpdateArgs) {
        if (self.left_vel == 1 && self.left_pos < 291)
            || (self.left_vel == -1 && self.left_pos >= 1)
        {
            self.left_pos += self.left_vel;
        }
        if (self.right_vel == 1 && self.right_pos < 291)
            || (self.right_vel == -1 && self.right_pos >= 1)
        {
            self.right_pos += self.right_vel;
        }
.       
.
.

The next part gives our ball some velocity. We want our ball to be moving in the x direction always so we increment it with our velocity x.

self.ball_x += self.vel_x;

We want to check to see if the ball has gone off of the right side of the screen, if self.ball_x > 502. If it is, we want to then reverse its velocity in the x direction, self.vel_x = -self.vel_x; Basically, if it's going right and it hits the paddle then it will automatically go to the left. If it's going right and it misses the paddle and goes off the screen then when we reset it. It will automatically be moving towards the left paddle. Then we check to see if ball y self.ball_y < self.right_pos || self.ball_y > self.right_pos + 50. In other words, we check to see if our ball has gone past our right paddle and if it has, then we increment our left score +1, self.left_score += 1;

Then we have a little statement that says that if self.left_score >= 5 then we say println!("Left wins!"); and then we process::exit(0);. If we do go past the right paddle then we want to reset the ball at 256 for its ball_x and 171 for its ball_y.

    self.ball_x += self.vel_x;
        if self.ball_x > 502 {
            self.vel_x = -self.vel_x;
            if self.ball_y < self.right_pos || self.ball_y > self.right_pos + 50 {
                self.left_score += 1;
                if self.left_score >= 5 {
                    println!("Left wins!");
                    process::exit(0);
                }
                self.ball_x = 256;
                self.ball_y = 171;
            }
        }

Similarly, we'll work on the left paddle. You can check the code below for minor differences.

        if self.ball_x < 1 {
            self.vel_x = -self.vel_x;
            if self.ball_y < self.left_pos || self.ball_y > self.left_pos + 50 {
                self.right_score += 1;
                if self.right_score >= 5 {
                    println!("Right wins!");
                    process::exit(0);
                }
                self.ball_x = 256;
                self.ball_y = 171;
            }
        }

The last thing we want in this method is to allow our ball to bounce off of the top and the bottom of the screen. So we will do self.ball_y += self.vel_y;. Then we want to check to see if the ball has hit the bottom of the screen or if it has hit the top of the screen. If it has hit either one then we reverse the velocity.

        self.ball_y += self.vel_y;
        if self.ball_y > 332 || self.ball_y < 1 {
            self.vel_y = -self.vel_y;
        }

The press method

So now we want to create a method to handle the keys. We will call this method press. It takes in our immutable self and the arguments which is a reference to our Button enum. We will use an if let binding to destruct our arguments and if the pattern is similar to a reference to Button keyboard then we want to take the key out and we want to match on key.

  • If Key::Up โ†’ we want the right paddle's velocity to move -1.

  • If Key::Down โ†’ we want the right velocity to move 1

  • Likewise, we'll code w,s, and any other key _.

fn press(&mut self, args: &Button) {
        if let &Button::Keyboard(key) = args {
            match key {
                Key::Up => {
                    self.right_vel = -1;
                }
                Key::Down => {
                    self.right_vel = 1;
                }
                Key::W => {
                    self.left_vel = -1;
                }
                Key::S => {
                    self.left_vel = 1;
                }
                _ => {}
            }
        }
    }

The release method

Now we also want to have a release method. It will be the same as our press method except for one minor difference. We're going to run an if let binding on args to see if let &Button::Keyboard(key) = args and if it does then we're going to match on key and:

  • If Key::Up was pressed but let go then we want to set the right paddle's velocity to 0 (to elaborate, if for instance the player is hitting up and then they let go then we want to immediately stop the velocity.)

  • Likewise, we'll code Down, w,s, and any other key _.

fn release(&mut self, args: &Button) {
        if let &Button::Keyboard(key) = args {
            match key {
                Key::Up => {
                    self.right_vel = 0;
                }
                Key::Down => {
                    self.right_vel = 0;
                }
                Key::W => {
                    self.left_vel = 0;
                }
                Key::S => {
                    self.left_vel = 0;
                }
                _ => {}
            }
        }
    }

The main function

Let's finally build up the window and make the game work! ๐Ÿค—

We're going to bind let opengl = OpenGL::V3_2; , then we're going to say let mut window: GlutinWindow = WindowSettings::new("Pong", [512, 342]) with pong's dimensions at 512 by 342 and then we'll have .exit_on_esc(true) so that if an individual hits the escape key it will exit the window, then we want to build the window and we want to unwrap it so that we can get back the actual value.

fn main() {
    let opengl = OpenGL::V3_2;
    let mut window: GlutinWindow = WindowSettings::new("Pong", [512, 342])
        /* .opengl(opengl) */
        .exit_on_esc(true)
        .build()
        .unwrap();

Now we want to instantiate our app structure and let mu app equal to App. Then we're going to have:

  • Our gl is our GlGraphics::new(opengl) so this will be bound to our opengl buffer.

  • We want to set our left_score equal to 0, our left_pos equal to 1and the left_ vel equal to 0.

  • Likewise for the right side.

  • Our ball_x and ball_y will be 0 with its vel_x and vel_y being 1.

 let mut app = App {
        gl: GlGraphics::new(opengl),
        left_score: 0,
        left_pos: 1,
        left_vel: 0,
        right_score: 0,
        right_pos: 1,
        right_vel: 0,
        ball_x: 0,
        ball_y: 0,
        vel_x: 1,
        vel_y: 1,
    };

Next, we want to create an events variable, this will let mut events = Events::new(EventSettings::new());. Now let's create a loop. The loop will continue to iterate through as long as we have a new event. And we finally want to call the functions we created above according to our user's action (key presses).

    let mut events = Events::new(EventSettings::new());
    while let Some(e) = events.next(&mut window) {
        if let Some(r) = e.render_args() {
            app.render(&r);
        }

        if let Some(u) = e.update_args() {
            app.update(&u);
        }

        if let Some(b) = e.press_args() {
            app.press(&b);
        }

        if let Some(b) = e.release_args() {
            app.release(&b);
        }
    }

Run it ๐Ÿƒโ€โ™‚๏ธ

Are you still here? That's it folks, we made it! Time to run it.

Simply type in your terminal cargo run and you should see the board. Call a friend and start playing. ๐Ÿค—

Use the keys w and s and your friend the arrows up and down. Who is going to win?!

Find the code here:

Happy Rust Coding! ๐Ÿคž๐Ÿฆ€


๐Ÿ‘‹ Hello, I'm Eleftheria, Community Manager, developer, public speaker, and content creator.

๐Ÿฅฐ If you liked this article, consider sharing it.

๐Ÿ”— All links | X | LinkedIn

48
Subscribe to my newsletter

Read articles from Eleftheria Batsou directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Eleftheria Batsou
Eleftheria Batsou

Hi there ๐Ÿ™†โ€โ™€๏ธ, I'm Eleftheria, Community Manager with a coding background and a passion for UX. Do you have any questions? Don't hesitate to contact me. I can talk about front-end development, design, job hunting/freelancing/internships.