Introduction
This project consists in replicating the famous Pong game originally released for the Atari console released back in 1972. The idea was to learn some basic game development in Rust, since I am keen on becoming a better Rust programmer and wanted to try my hand at some game development, also using Rust.
I decided to use a library for 2D game development in Rust called Macroquad. It is simple yet feature full, perfect for someone getting started. It makes it easy to render images on screen, which can sometimes be the most daunting part of game development if you aren’t using a framework or game engine. This makes it easy to get results, which can sometimes scare beginners away.
Plan and Basic Features
The plan is to have a basic Pong implementation with the following features:
- 2 paddles and 1 ball (obviously!).
- Numbers to indicate each players score.
- Player 1 (left side) is controlled with the ‘w’ and ’s’ keys, while player 2 (right side) is controlled with the up and down arrow keys.
The code
The full code for the game can be found on my GitHub repository. The following sections of this post discuss implementing the different parts necessary to make the game.
Getting started
First, we need a main game loop where all the actions and checks will take place, like updating the ball’s position, and checking if buttons have been pressed to move the players up or down. At the end of the game loop, we must always wait for the next frame to be available.
#[macroquad::main(conf)]
async fn main() {
// Initialize variables and stuff
loop {
clear_background(GRAY);
// Do game stuff
next_frame().await;
}
}
First, let’s configure the window size and other parameters of the game. This is done by implementing a conf
function as shwon below. You can change the WINDOW_WIDTH
and WINDOW_HEIGHT
parameters to your desired window resolution.
const WINDOW_HEIGHT: i32 = 400;
const WINDOW_WIDTH: i32 = 600;
fn conf() -> Conf {
Conf {
window_title:"Pong".to_owned(),
window_width:WINDOW_WIDTH,
window_height:WINDOW_HEIGHT,
window_resizable: false,
..Default::default()
}
}
For the player, we can make a struct to hold its position and score. For the ball, we need its position and its direction as well. A Point struct is used as a base for defining x
and y
positions. The structs shown below will serve as the base for our game.
#[derive(Debug, Clone, Copy)]
struct Point {
x: f32,
y: f32
}
#[derive(Debug, Clone, Copy)]
struct Player {
pos: Point,
score: i16,
}
struct Ball {
pos: Point,
dir: Point
}
If you don’t know too much rust and are scared of the #[derive]
statements, don’t worry! They have to do with how Rust checks the ownership of objects, which is beyond the scope of this post. If you want to read up more on the concept of ownership and how the Rust compiler enforces certain memory checks, you can read this section of the Rust book.
The Debug derive makes it easy to print variables of these types to see what they contain.
Lastly, we can create a check_move_player
function, to check and move the player paddles up and down if the respective control keys have been pressed.
fn check_move_player(p: &mut Player, keycode_up: KeyCode, keycode_down: KeyCode) {
if is_key_down(keycode_up) && p.pos.y > 0.{
p.pos.y -= PLAYER_SPEED;
} else if is_key_down(keycode_down) && p.pos.y < screen_height() - RECTANGLE_HEIGHT {
p.pos.y += PLAYER_SPEED;
}
}
Then we can use that function in the main loop, and pass the keycodes we want to check for depending on each player.
#[macroquad::main(conf)]
async fn main() {
// ...
loop {
// ...
// p1 movement
check_move_player(&mut p1, KeyCode::W, KeyCode::S);
// p2 movement
check_move_player(&mut p2, KeyCode::Up, KeyCode::Down);
// ...
next_frame().await;
}
}
Moving and Drawing Player
Drawing the player is fairly simple. We create a function that takes a player as its argument, and simply draws a black rectangle. Here I used macroquad’s draw_rectangle
function, which takes the top-left corner position at [x, y] of the rectangle you want to draw. The parameters RECTANGLE_WIDTH
and RECTANGLE_HEIGHT
can be configured to the desired height and width of the player.
const RECTANGLE_WIDTH: f32 = 15.;
const RECTANGLE_HEIGHT: f32 = 80.;
fn draw_player(p: Player) {
draw_rectangle(p.pos.x, p.pos.y, RECTANGLE_WIDTH, RECTANGLE_HEIGHT, BLACK);
}
In order for the players to be positioned correctly in the board, their initial position needs to be determined based on their size and the size of the window. The way the coordinates work is that the (0,0) position is at the top left, and any increments to x means moving to the right, and any increments in y translate to moving down.
For the player on the left (player 1 in this case), the x position is simply equal to OFFSET
. This parameter controls how far from the edges the player is positioned. The y position is the same for both players. First we divide the screen height by 2, to get a centered y position. Then we subtract the player height divided by 2, so the player spawns perfectly centered. The x position of player 2 is the screen width minus the OFFSET
parameter to move the player “off the wall” and then minus the player width, to compensate for the player’s width.
const OFFSET: f32 = 20.;
#[macroquad::main(conf)]
async fn main() {
// Center the players at first
let mut p1: Player = Player {
pos: Point {
x: OFFSET,
y: screen_height()/2. - RECTANGLE_HEIGHT/2.
},
score: (0)
};
let mut p2: Player = Player {
pos: Point {
x: screen_width() - OFFSET - RECTANGLE_WIDTH,
y: screen_height()/2. - RECTANGLE_HEIGHT/2.
},
score: (0)
};
loop {
// ...
next_frame().await;
}
}
Moving and Drawing Ball
Drawing the ball is simple, and similar to drawing the players. First we spawn the ball in the center of the screen, and continuously draw it in the game loop.
const CUBE_SIDE: f32 = 10.;
#[macroquad::main(conf)]
async fn main() {
let mut ball: Ball = Ball { pos: Point { x: screen_width()/2., y: screen_height()/2. }, dir: Point {x: point.x, y: point.y}};
loop {
// ...
draw_rectangle(ball.pos.x, ball.pos.y, CUBE_SIDE, CUBE_SIDE, GOLD);
// ...
next_frame().await;
}
}
We haven’t however updated the ball’s position yet. For that we can create a move_ball
function. This function will update the ball’s position every iteration of the game loop, depending on the position of the ball, and its direction vector suppied as a unit vecotr.
// dir of ball should be unit vector
fn move_ball(b: &mut Ball) {
b.pos.x += b.dir.x * BALL_SPEED;
b.pos.y += b.dir.y * BALL_SPEED;
// Wall collision
if b.pos.y > screen_height()-CUBE_SIDE || b.pos.y < 0.0{
b.dir.y = -b.dir.y;
}
}
Now the only thing left is to get an initial direction vector for the ball once it spawns. We can create a new function get_new_ball_dir
that generates a random direction vector. Essentially, this function generates 2 random numbers, one for the x direction and one for the y direction. These numbers were chosen to be between (0.25 ..0.5)
for the x direction and between (-0.25 ..0.25)
for the y direction. This is so the ball does not spawn with a fully vertical direction (i.e. direction vector (0,1)
). There is a bug where the direction vector has a very small chance of being (0,0)
, which is a pending fix.
// function to spawn ball in middle with random direction
fn get_new_ball_dir() -> Point {
let mut rng = thread_rng();
let dir_x = rng.gen_range(0.25 ..0.5);
let dir_y = rng.gen_range(-0.25 ..0.25);
let modulus = ((dir_x*dir_x + dir_y*dir_y) as f64).sqrt();
let unit_x: f32 = (dir_x / modulus) as f32;
let unit_y: f32 = (dir_y / modulus) as f32;
Point { x: unit_x, y: unit_y }
}
Putting it all together, this should result in the following code.
#[macroquad::main(conf)]
async fn main() {
// Center the players at first
// ...
let point = get_new_ball_dir();
let mut ball: Ball = Ball {
pos: Point {
x: screen_width()/2.,
y: screen_height()/2.
},
dir: Point {
x: point.x,
y: point.y}
};
loop {
// ...
// Ball movement
move_ball(&mut ball);
// ...
next_frame().await;
}
}
Scoring points
Scoring points is simple, since it is a matter of checking wether the ball has made it all the way to the left or right. We can create a check_scored_points
function that checks exactly this, and respawns the ball in the center and travelling in the direction of the player that just scored. The scores are updated and stored in the player variable.
fn check_scored_points(b: &mut Ball, p1 : &mut Player, p2 : &mut Player) {
// If ball surpasses x axis on either side, players score points
// ball should go toward player that won
if b.pos.x > screen_width()-CUBE_SIDE {
// b.dir.x = -b.dir.x;
p1.score += 1;
// reset ball pos
b.pos.x = screen_width()/2.;
b.pos.y = screen_height()/2.;
let point = get_new_ball_dir();
b.dir.x = -point.x;
b.dir.y = point.y;
} else if b.pos.x < 0.0 {
p2.score += 1;
b.pos.x = screen_width()/2.;
b.pos.y = screen_height()/2.;
let point = get_new_ball_dir();
b.dir.x = point.x;
b.dir.y = point.y;
}
}
To know what the score is, we can draw the scores of each player on the top corners of the screen.
fn draw_scores(p1 : &Player, p2 : &Player) {
let text_params = TextParams {
font_size:70,
..Default::default()
};
draw_text_ex(&format!("{}",p1.score).as_str(),100., 100.,text_params);
draw_text_ex(&format!("{}",p2.score).as_str(),screen_width()-100., 100.,text_params);
}
Checking collisions
Thus far the game takes care of moving and respawning the ball, and the players can move up and down, but there are no collisions yet, which means that the ball does not bounce back when hitting the player paddles.
To achieve this, we can create a ball_collision_with_player
function to check collisions with the players. This function checks if the ball and player rectangles intersect, and if they do, changes the ball x direction by changing the sign of its direction vector.
fn ball_collision_with_player(player : &Player, ball : &mut Ball) -> bool{
let mut result = false;
let ball_rect = Rect::new(ball.pos.x, ball.pos.y, CUBE_SIDE, CUBE_SIDE);
let player_rect = Rect::new(player.pos.x, player.pos.y, RECTANGLE_WIDTH, RECTANGLE_HEIGHT);
if ball_rect.intersect(player_rect).is_some() {
result = true;
ball.dir.x = -ball.dir.x;
}
result
}
One bug that arose when using this function was that if the top edge of the player rectangle was hit, the ball would bounce in the opposite direction, but since in the next frame the ball and the player were still intersecting, it changed the direction again. This caused the ball to be “trapped” inside the player paddle. To fix this, we can add a grace period in the game loop, telling it to wait a number of frames before checking for collisions again.
const WAIT_BETWEEN_COLLISIONS: i32 = 60;
#[macroquad::main(conf)]
async fn main() {
// ...
let counter = WAIT_BETWEEN_COLLISIONS;
loop {
// ...
// Once there has been a collision, wait some time before checking again.
// This gets rid of a bug where the ball was still intersecting with the player and would bounce back internally.
if counter > 0 {
counter = counter -1;
} else{
let p1_collision = ball_collision_with_player(&p1, &mut ball);
let p2_collision = ball_collision_with_player(&p2, &mut ball);
if p1_collision || p2_collision{
counter = WAIT_BETWEEN_COLLISIONS;
}
}
next_frame().await;
}
}
Conclusion
The simplicity of the Macroquad library removed many of the first hurdles I expected to have programming a game in Rust, like worrying about how to draw to the screen. It also made it straightforward to test the results of each change made, since it is lightweight, and compilation times were extremely quick.
There are however more features that I would like to add to the game, including the following:
- Self serve.
- A configuration menu, to change controls, window size and other parameters.
- A configurable cap on the amount of points.
- Fixing the
(0,0)
direction vector bug. - Adding some randomness in the bounce back direction of the ball, or making it dependent on how the player was moving previously.
Hope it was helpful! Please share this on social media if you know someone that it might help! Links below!