DEV Community

Flavius
Flavius

Posted on

Pong: With Rust and Macroquad

Game Preview

Introduction

In this short tutorial, learn how to make a two-player pong game in Rust.
We will be using Macroquad for this.

Prerequisites

Having Rust installed on your machine.
Some basic knowledge of Rust (structs, tuples, async/await, loops and random numbers etc.).
If you want to learn some basics of Macroquad, click here

Getting Started

Create a new Rust project:

cargo new pong
Enter fullscreen mode Exit fullscreen mode

Add Macroquad and Rand Crate:

Either add it in your Cargo.toml file:

[dependencies]
macroquad = "0.3.25"
rand = "0.8.5"
# these were the latest versions on the time of making this post
Enter fullscreen mode Exit fullscreen mode

Or add it using cargo add:

cargo add macroquad
cargo add rand
Enter fullscreen mode Exit fullscreen mode

Blank Window

In your src/main.rs file, add:

use macroquad::prelude::*;

#[macroquad::main("Pong")]
async fn main() {
   loop {
     clear_background(SKYBLUE);

     next_frame().await;
   }
}
Enter fullscreen mode Exit fullscreen mode

This sets up a window with sky-blue as the background color.
Click here for some explanation of the code.

If you wanted more control over the window properties:

//snip
fn conf() -> Conf {
      Conf {
      window_title:"Pong".to_owned(),
      ...
    }
}

#[macroquad::main(conf)]
Enter fullscreen mode Exit fullscreen mode

Specify custom properties to Conf struct. For this tutorial, I'll set window_resizeable to false.

Creating Paddles

Let's start by making a Paddle struct.

//snip
#[derive(Copy,Clone)]
struct Paddle {
    rect:Rect,
}
Enter fullscreen mode Exit fullscreen mode

The Rect has these fields:

  • The X-Coordinate
  • The Y-Coordinate
  • The width of the rectangle
  • The height of the rectangle This will make it easier for us to manage the position and size of the paddles. Speaking of size, let's create some constants for the height and width of the paddles and also their color:
// snip
const PADDLE_W:f32 = 20f32;
const PADDLE_H:f32 = 80f32;
const PADDLE_COLOR:Color = DARKBLUE;
Enter fullscreen mode Exit fullscreen mode

Implementations for Paddle struct:

// snip
impl Paddle {
    fn new(rect:Rect) -> Self {
        Self {
            rect,
        }
    }

    fn draw(&self) {
        let r = self.rect;
        draw_rectangle(r.x,r.y,r.w,r.h,PADDLE_COLOR);
    }
}
Enter fullscreen mode Exit fullscreen mode

The new method to create a new instance of Paddle and draw function to draw the paddle onto the screen.
the draw_rectangle function takes the x and y position and width and height (all of f32 type) and also the color to draw.
screen_width() and screen_height() return the width and height of the screen respectively. We assign the x and y values to be half of the width and height of the screen so that the paddle is at the center of the window.

Coordinate system of Macroquad, where the black rectangle is the screen:

Coordinate system

In your main function, create a paddle and draw it on to the screen:

//snip
async fn main() {
    let paddle = Paddle::new(Rect::new(screen_width()/2.,screen_height()/2.,PADDLE_W,PADDLE_H));
    loop {
        clear_background(SKYBLUE);
        paddle.draw();
        next_frame().await();
    }
}
Enter fullscreen mode Exit fullscreen mode

The resulting code should give:
Gamwe Prevbiew

And the code:

use macroquad::prelude::*;

const PADDLE_W:f32 = 20f32;
const PADDLE_H:f32 = 80f32;
const PADDLE_COLOR:Color = DARKBLUE;

fn conf() -> Conf {
      Conf {
      window_title:"Pong".to_owned(),
      window_resizable: false,
      ..Default::default()
    }
}

#[derive(Copy,Clone)]
struct Paddle {
    rect:Rect,
}

impl Paddle {
    fn new(rect:Rect) -> Self {
        Self {
            rect,
        }
    }

    fn draw(&self) {
        let r = self.rect;
        draw_rectangle(r.x,r.y,r.w,r.h,PADDLE_COLOR);
    }
}

#[macroquad::main("Pong")]
async fn main() {
   let paddle = Paddle::new(Rect::new(screen_width()/2.,screen_height()/2.,PADDLE_W,PADDLE_H));
   loop {
     clear_background(SKYBLUE);

     paddle.draw(); // Make sure its after the clear_background function
                    // else it cannot be seen

     next_frame().await;
   }
}
Enter fullscreen mode Exit fullscreen mode

Now let's make two paddles, one for the right side and another for the left. Modify your main function:

//don't worru about the unused mut warning,we'll use them later.
  let mut paddle_left = Paddle::new(Rect::new(PADDLE_W,screen_height()/2.,PADDLE_W,PADDLE_H));
   let mut paddle_right = Paddle::new(Rect::new(screen_width()-PADDLE_W*2.,screen_height()/2.,PADDLE_W,PADDLE_H));

   loop {
     clear_background(SKYBLUE);

     paddle_left.draw();
     paddle_right.draw();
     next_frame().await;
   }
Enter fullscreen mode Exit fullscreen mode

Creating two new paddles for the left and the right and aligning them properly, and drawing them on the screen
Output:
Game Preview

Movement

Create a new constant for the paddle speed:


const PADDLE_SPEED:f32 = 10f32;

Enter fullscreen mode Exit fullscreen mode

In your Paddle struct, create a new implmentation for movement.


impl Paddle {
  ...
 fn movement(&mut self,up:KeyCode,down:KeyCode) {
        if is_key_down(up) {
            self.rect.y -= 1.*PADDLE_SPEED;
        }else if is_key_down(down) {
            self.rect.y += 1.*PADDLE_SPEED;
        }
    }
  ...
}

Enter fullscreen mode Exit fullscreen mode

We need to change the position (x and y coordinates) of the rect, so we get a mutable refernce. Since we have two paddles with two different keys for movement, we get the keys through the parameters.

`is_key_down function takes a KeyCode and returns true if the key is being held down.
If the key to move up is held down, then we move the paddle up by negating the y coordinate multiplied with the paddle speed.
Same logic to move down, but now we add to the y coordinate.

In your main function loop:


rs

  //snip
   ...
     paddle_left.movement(KeyCode::W,KeyCode::S);
     paddle_right.movement(KeyCode::Up, KeyCode::Down);   
     paddle_left.draw();
     paddle_right.draw();
   ...



Enter fullscreen mode Exit fullscreen mode

And now you should be able to move the paddles using the keys!
Here's the whole code till now:


rs

use macroquad::prelude::*;

const PADDLE_W:f32 = 20f32;
const PADDLE_H:f32 = 80f32;
const PADDLE_COLOR:Color = DARKBLUE;
const PADDLE_SPEED:f32 = 10f32;
fn conf() -> Conf {
      Conf {
      window_title:"Pong".to_owned(),
      window_resizable: false,
      ..Default::default()
    }
}

#[derive(Copy,Clone)]
struct Paddle {
    rect:Rect,
}

impl Paddle {
    fn new(rect:Rect) -> Self {
        Self {
            rect,
        }
    }

    fn movement(&mut self,up:KeyCode,down:KeyCode) {
        if is_key_down(up) {
            self.rect.y -= 1.*PADDLE_SPEED;
        }else if is_key_down(down) {
            self.rect.y += 1.*PADDLE_SPEED;
        }
    }

    fn draw(&self) {
        let r = self.rect;
        draw_rectangle(r.x,r.y,r.w,r.h,PADDLE_COLOR);
    }
}

#[macroquad::main(conf)]
async fn main() {
  let mut paddle_left = Paddle::new(Rect::new(PADDLE_W,screen_height()/2.,PADDLE_W,PADDLE_H));
   let mut paddle_right = Paddle::new(Rect::new(screen_width()-PADDLE_W*2.,screen_height()/2.,PADDLE_W,PADDLE_H));

   loop {
     clear_background(SKYBLUE);
     paddle_left.movement(KeyCode::W,KeyCode::S);
     paddle_right.movement(KeyCode::Up, KeyCode::Down);   
     paddle_left.draw();
     paddle_right.draw();
     next_frame().await;
   }
}



Enter fullscreen mode Exit fullscreen mode

But you'll probably note that the paddles can go out of the screen.
To restrict the paddles into the screen, add this in your movement implementation:


rs
        if self.rect.y > screen_height()-PADDLE_H{
            self.rect.y = screen_height()-PADDLE_H;
        }else if self.rect.y < 0. {
            self.rect.y = 0.;
        }



Enter fullscreen mode Exit fullscreen mode

Just some simple logic.
If the paddle is gone above screen (less than 0) then we bring it back to the screen (0). If its below the screen (screen_height()-PADDLE_H) then get it back to the screen.

Adding the Ball

Create some constants for the ball:


rs

const BALL_RADIUS:f32 = 15f32;
const BALL_COLOR:Color = PINK;
const BALL_SPEED:f32 = 8f32;



Enter fullscreen mode Exit fullscreen mode

Create the Ball struct


rs

#[derive(Copy,Clone)]
struct Ball {
    circle:Circle,
    dir: Vec2 // direction or velocity of the ball
}



Enter fullscreen mode Exit fullscreen mode

dir is the velocity or the direction towards which the ball should travel.
Implementations for the ball:


rs

impl Ball {
    fn new(circle:Circle) ->Self {
        Self {
            circle,
            dir:vec2(0.,1.) // for now, we'll use these
                            // we'll add random values later
        }
    }

    fn draw(&self) {

draw_circle(self.circle.x,self.circle.y,self.circle.r,BALL_COLOR);
    }
}



Enter fullscreen mode Exit fullscreen mode

Add the ball to the game:


rs

async fn main() {
   ...
   let mut ball = Ball::new(Circle::new(screen_width()/2.,screen_height()/2.,BALL_RADIUS)); 
   loop {
     clear_background(SKYBLUE); //MAKE SURE: all the drawing
                                // happens below this function
     ...
     ball.draw();
     ...
   }
}



Enter fullscreen mode Exit fullscreen mode

And on running it:

Image description

Great! Now to make it move, we simple add the dir value (x and y) multiplied by the speed to the balls's position.
Add another function to the ball implementation:


rs

    fn move_ball(&mut self) {
         self.circle.x += self.dir.x * BALL_SPEED;
         self.circle.y += self.dir.y * BALL_SPEED;
    }



Enter fullscreen mode Exit fullscreen mode

And move the ball in your loop:


rs

//snip
 loop {
     ... 
     ball.move_ball();
     ...
   }



Enter fullscreen mode Exit fullscreen mode

Now the ball moves...out of the window. But this has an easy fix!
In your move_ball function:


rs

       ...
       if self.circle.y > screen_height()-BALL_RADIUS || self.circle.y < 0.0 {
            self.dir.y = -self.dir.y;
        }
      ...



Enter fullscreen mode Exit fullscreen mode

We invert the sign of dir.y so that it goes the other way. Now if you run this you can see that the ball moves up and down the screen. We aren't doing this with the x value because we will use that for scoring system.

Random dir value

Import rand crate and some of its items:


rs

use ::crate::{thread_rng,Rng};


Enter fullscreen mode Exit fullscreen mode

In your new function for ball, make these changes:


rs
 let mut rng = thread_rng();
        let dir_x = rng.gen_range(-1.0..1.);
        let dir_y = rng.gen_range(-1.0..1.);
        Self {
            circle,
            dir:vec2(dir_x,dir_y)
        }



Enter fullscreen mode Exit fullscreen mode

Done!
The code so far:


rs

use macroquad::prelude::*;
use ::rand::{thread_rng,Rng};

const PADDLE_W:f32 = 20f32;
const PADDLE_H:f32 = 80f32;
const PADDLE_COLOR:Color = DARKBLUE;
const PADDLE_SPEED:f32 = 10f32;

const BALL_RADIUS:f32 = 15f32;
const BALL_COLOR:Color = PINK;
const BALL_SPEED:f32 = 8f32;

fn conf() -> Conf {
      Conf {
      window_title:"Pong".to_owned(),
      window_resizable: false,
      ..Default::default()
    }
}

#[derive(Copy,Clone)]
struct Paddle {
    rect:Rect,
}

#[derive(Copy,Clone)]
struct Ball {
    circle:Circle,
    dir: Vec2
}


impl Paddle {
    fn new(rect:Rect) -> Self {
        Self {
            rect,
        }
    }

    fn movement(&mut self,up:KeyCode,down:KeyCode) {
        if is_key_down(up) {
            self.rect.y -= 1.*PADDLE_SPEED;
        }else if is_key_down(down) {
            self.rect.y += 1.*PADDLE_SPEED;
        }

        if self.rect.y > screen_height()-PADDLE_H{
            self.rect.y = screen_height()-PADDLE_H;
        }else if self.rect.y < 0. {
            self.rect.y = 0.;
        }

    }

    fn draw(&self) {
        let r = self.rect;
        draw_rectangle(r.x,r.y,r.w,r.h,PADDLE_COLOR);
    }
}

impl Ball {
    fn new(circle:Circle) ->Self {
        let mut rng = thread_rng();
        let dir_x = rng.gen_range(-1.0..1.);
        let dir_y = rng.gen_range(-1.0..1.);
        Self {
            circle,
            dir:vec2(dir_x,dir_y)
        }
    }

    fn move_ball(&mut self) {
         self.circle.x += self.dir.x * BALL_SPEED;
         self.circle.y += self.dir.y * BALL_SPEED;

        if self.circle.y > screen_height()-BALL_RADIUS || self.circle.y < 0.0 {
            self.dir.y = -self.dir.y;
        }

    }

    fn draw(&self) {
        draw_circle(self.circle.x,self.circle.y,self.circle.r,BALL_COLOR);
    }
}

#[macroquad::main(conf)]
async fn main() {
  let mut paddle_left = Paddle::new(Rect::new(PADDLE_W,screen_height()/2.,PADDLE_W,PADDLE_H));
   let mut paddle_right = Paddle::new(Rect::new(screen_width()-PADDLE_W*2.,screen_height()/2.,PADDLE_W,PADDLE_H));
   let mut ball = Ball::new(Circle::new(screen_width()/2.,screen_height()/2.,BALL_RADIUS)); 
   loop {
     clear_background(SKYBLUE);
     paddle_left.movement(KeyCode::W,KeyCode::S);
     paddle_right.movement(KeyCode::Up, KeyCode::Down);   
     ball.move_ball();
     ball.draw();
     paddle_left.draw();
     paddle_right.draw();
     next_frame().await;
   }
}



Enter fullscreen mode Exit fullscreen mode

Collisions!

Now we'll add ball-to-paddle collisions.
Create a new implementation in your ball struct:


rust

    fn collision_with_paddle(&mut self, paddle_1 : &Rect, paddle_2 : &Rect) {
        let ball_rect = Rect::new(self.circle.x,self.circle.y,self.circle.r,self.circle.r);
        if ball_rect.intersect(*paddle_1).is_some() || ball_rect.intersect(*paddle_2).is_some() {
            self.dir.x = -self.dir.x;
        }
    }



Enter fullscreen mode Exit fullscreen mode

We take in two refernces of paddles. Then we create a rect of the ball because the rect has a method intersect which allows us to see if a rect is touching or intersecting another rect. We pass the x and y as coordinates and radius as the height and width (more of a square than a rectangle then).
Then we check if any one of the paddles intersect with the ball and then invert the sign of the dir.x value. Easy!

Call the method in your loop:


rs
loop {
 ...
 ball.collision_with_paddles(&paddle_left.rect,&paddle_right.rect);
 ...
}


Enter fullscreen mode Exit fullscreen mode

Run it!
And now you can play pong until one of you loses the ball.

Ball respawn and scores

Create a scores tuple variable in your main function (outside the loop):


rs
let mut scores = (0,0);


Enter fullscreen mode Exit fullscreen mode

Now, inside the loop, we need to check for the ball position and accordingly update the scores:


rs

     if ball.circle.x < 0.0 {
         ball = Ball::new(Circle::new(screen_width()/2.,screen_height()/2.,BALL_RADIUS));         
         scores.1 += 1;
     }else if ball.circle.x > screen_width() {
         ball = Ball::new(Circle::new(screen_width()/2.,screen_height()/2.,BALL_RADIUS));        
         scores.0 += 1;
    }


Enter fullscreen mode Exit fullscreen mode

What good are scores if you cannot view them?

Drawing the scores

Macroquad has functions for drawing text onto the screen. I don't really like the font, so I'll use Robot-Black. Create a folder res in your project directory (outside src but inside your project) and install the ttf version of the font there.
Now let's access it in our main function:


rs
      let font =  load_ttf_font("./res/Roboto-Black.ttf")
        .await
        .unwrap();


Enter fullscreen mode Exit fullscreen mode

Create a new function to draw the text:


rs

fn draw_scores(font:Font,scores:&(u32,u32)) {
        let text_params =  TextParams {
            font_size:70,
            font,
            ..Default::default()
        };

        draw_text_ex(&format!("{}",scores.0).as_str(),100., 100.,text_params);
        draw_text_ex(&format!("{}",scores.1).as_str(),screen_width()-100., 100.,text_params);
}


Enter fullscreen mode Exit fullscreen mode

We take in the font and scores. We assign text_params to a struct which allows us to customize how our text looks.(More Info). Now we draw the text using draw_text_ex at the left and right side.

Then calling it in our main loop:


rs
loop {
   ...
   clear_background(); //again, always draw stuff below this
   ...

   draw_scores(font,&scores);
   ...
}


Enter fullscreen mode Exit fullscreen mode

And we're done!
Here's the full code:


rs
use macroquad::prelude::*;
use ::rand::{thread_rng,Rng};

const PADDLE_W:f32 = 20f32;
const PADDLE_H:f32 = 80f32;
const PADDLE_COLOR:Color = DARKBLUE;
const PADDLE_SPEED:f32 = 10f32;

const BALL_RADIUS:f32 = 15f32;
const BALL_COLOR:Color = PINK;
const BALL_SPEED:f32 = 8f32;

fn conf() -> Conf {
      Conf {
      window_title:"Pong".to_owned(),
      window_resizable: false,
      ..Default::default()
    }
}

#[derive(Copy,Clone)]
struct Paddle {
    rect:Rect,
}

#[derive(Copy,Clone)]
struct Ball {
    circle:Circle,
    dir: Vec2
}


impl Paddle {
    fn new(rect:Rect) -> Self {
        Self {
            rect,
        }
    }

    fn movement(&mut self,up:KeyCode,down:KeyCode) {
        if is_key_down(up) {
            self.rect.y -= 1.*PADDLE_SPEED;
        }else if is_key_down(down) {
            self.rect.y += 1.*PADDLE_SPEED;
        }

        if self.rect.y > screen_height()-PADDLE_H{
            self.rect.y = screen_height()-PADDLE_H;
        }else if self.rect.y < 0. {
            self.rect.y = 0.;
        }

    }

    fn draw(&self) {
        let r = self.rect;
        draw_rectangle(r.x,r.y,r.w,r.h,PADDLE_COLOR);
    }
}

impl Ball {
    fn new(circle:Circle) ->Self {
        let mut rng = thread_rng();
        let dir_x = rng.gen_range(-1.0..1.);
        let dir_y = rng.gen_range(-1.0..1.);
        Self {
            circle,
            dir:vec2(dir_x,dir_y)
        }
    }

    fn move_ball(&mut self) {
         self.circle.x += self.dir.x * BALL_SPEED;
         self.circle.y += self.dir.y * BALL_SPEED;

        if self.circle.y > screen_height()-BALL_RADIUS || self.circle.y < 0.0 {
            self.dir.y = -self.dir.y;
        }

    }

    fn collision_with_paddle(&mut self, paddle_1 : &Rect, paddle_2 : &Rect) {
        let ball_rect = Rect::new(self.circle.x,self.circle.y,self.circle.r,self.circle.r);
        if ball_rect.intersect(*paddle_1).is_some() || ball_rect.intersect(*paddle_2).is_some() {
            self.dir.x = -self.dir.x;
        }
    } 


    fn draw(&self) {
        draw_circle(self.circle.x,self.circle.y,self.circle.r,BALL_COLOR);
    }
}

fn draw_scores(font:Font,scores:&(u32,u32)) {
        let text_params =  TextParams {
            font_size:70,
            font,
            ..Default::default()
        };

        draw_text_ex(&format!("{}",scores.0).as_str(),100., 100.,text_params);
        draw_text_ex(&format!("{}",scores.1).as_str(),screen_width()-100., 100.,text_params);
}

#[macroquad::main(conf)]
async fn main() {
  let mut paddle_left = Paddle::new(Rect::new(PADDLE_W,screen_height()/2.,PADDLE_W,PADDLE_H));
   let mut paddle_right = Paddle::new(Rect::new(screen_width()-PADDLE_W*2.,screen_height()/2.,PADDLE_W,PADDLE_H));
   let mut ball = Ball::new(Circle::new(screen_width()/2.,screen_height()/2.,BALL_RADIUS)); 
    let mut scores:(u32,u32)= (0,0);

      let font =  load_ttf_font("./res/Roboto-Black.ttf")
        .await
        .unwrap();
    loop {
     clear_background(SKYBLUE);
     paddle_left.movement(KeyCode::W,KeyCode::S);
     paddle_right.movement(KeyCode::Up, KeyCode::Down);   
     ball.move_ball();
     ball.collision_with_paddle(&paddle_left.rect,&paddle_right.rect);
     ball.draw();
     paddle_left.draw();
     paddle_right.draw();

     if ball.circle.x < 0.0 {
         ball = Ball::new(Circle::new(screen_width()/2.,screen_height()/2.,BALL_RADIUS));         
         scores.1 += 1;
     }else if ball.circle.x > screen_width() {
         ball = Ball::new(Circle::new(screen_width()/2.,screen_height()/2.,BALL_RADIUS));        
         scores.0 += 1;
    }
        draw_scores(font,&scores);
     next_frame().await;
   }
}



Enter fullscreen mode Exit fullscreen mode

Useful Links

Top comments (0)