Coding arcade games in Rust: Pong

Jack Sim
CodeX
Published in
9 min readFeb 22, 2022

--

I’ve written a couple of articles now on creating Hangman in Rust and a Particle System in Rust and I’ve been looking for a new challenge… So the next step for me, having created a canvas based project with no inputs is to create a canvas-based game that allows user inputs. The most simple one I could think of was Pong, so here goes.

Creating the canvas

The first stage of the project is to load in the dependencies into the cargo.toml file. Doing this ensures the pre-built crates can be accessed within the project. The main two needed for creating the canvas and allowing the drawing of objects to the canvas are piston and piston_window.

.....
[dependencies]
piston = "0.53.0"
piston_window = "0.120.0"

Having loaded the dependencies into the project, the next step is to draw a blank canvas. The following template code can be used in the main.rs file to do this and is what I always use at the start of any project with the piston dependencies.

main.rsextern crate piston_window;use piston_window::*;
use piston_window::types::Color;
const WIDTH: u32 = 640;
const HEIGHT: u32 = 480;
const BACKGROUND_COLOUR: Color = [0.5, 0.5, 0.5, 1.0];
fn main() {
let mut window: PistonWindow = WindowSettings::new("Rusty Pong",
[WIDTH, HEIGHT])
.exit_on_esc(true)
.build()
.unwrap();
while let Some(event) = window.next() {
window.draw_2d(&e, |context, graphics, _device| {
clear(BACKGROUND_COLOUR, graphics);
});
}}

Draw a paddle to the window

Now that the game canvas has been created, it’s time to add objects to it. In Pong there are two paddles, one for the player and one for the computer-based opponent. To make these paddles, firstly a new file paddle.rs is created in the src directory of the project. This file will be used to create a struct called Paddle which will track the position on the window the paddle is to be drawn and have the functionality to display the paddle to the window. In addition to the position, the Paddle struct will track whether it is the user’s paddle or not with a boolean (True/False) feature.

paddle.rsuse piston_window::types::Color;
use piston_window::{rectangle, Context, G2d};
const PADDLE_COLOUR: Color = [1., 1., 1., 1.];
const PADDLE_WIDTH: f64 = 10.;
const PADDLE_HEIGHT: f64 = 100.;
pub struct Paddle {
pos_x: f64,
pos_y: f64,
is_user: bool,
}
impl Paddle { pub fn new(pos_x: f64, pos_y: f64, is_user: bool) -> Paddle {
Paddle {
pos_x: pos_x,
pos_y: pos_y,
is_user: is_user,
}
}

pub fn display(&self, con: &Context, g: &mut G2d) {
rectangle(
PADDLE_COLOUR,
[self.pos_x, self.pos_y,
PADDLE_WIDTH, PADDLE_HEIGHT,
con.transform,
g
);
}
}

The above code begins by importing the necessary attributes from the piston_window crate, these are the type Color, the function rectangle and the data types Context and G2d. The Color data type is used to define a PADDLE_COLOUR constant, which is used in the display function of the Paddle. The Context and G2d data types are related to the window that is created in the main.rs file in the previous step and allow the rectangle function, imported from piston_window, to draw the paddle into the game canvas.

When passing in the con argument to the display function, the “&” means that the function is referencing the object, rather than passing in the object itself. If the “&” was not used then the original object would not be located in the same memory space and could not be referred to again.

To implement the Paddle struct and draw it to the game canvas, in main.rs the struct must be imported, a variable created to contain the player Paddle struct and the display function called in the window draw loop.

main.rsextern crate piston_window;mod paddle;use piston_window::*;
use piston_window::types::Color;
use paddle::Paddle;const WIDTH: u32 = 640;
const HEIGHT: u32 = 480;
const BACKGROUND_COLOUR: Color = [0.5, 0.5, 0.5, 1.0];
fn main() {
let mut window: PistonWindow = WindowSettings::new("Rusty Pong",
[WIDTH, HEIGHT])
.exit_on_esc(true)
.build()
.unwrap();
let mut player = Paddle::new(0., 100., true);
while let Some(event) = window.next() {
window.draw_2d(&e, |context, graphics, _device| {
clear(BACKGROUND_COLOUR, graphics);
player.display(&context, graphics);
});
}}
Paddle drawn on left side of canvas
Adding the first paddle to the game canvas

Creating the opponent paddle is the same process as above, except the variable that will be assigned the Paddle struct will be called opponent.

Canvas with two paddles one at each side
Game canvas with two paddles drawn

Drawing and moving a ball

Similar to creating the Paddle, the first stage is to create a new file called ball.rs. This file will create a Ball struct that will track it’s x and y position as well as, the speed in x and y planes and the width and height of the canvas. Other than the new function in the implementation for the Ball struct there are two other functions: display (draw the ball to the canvas) and update (move the ball). The update function will check whether the ball’s position is outside the boundaries of the canvas, if it is outside in the y-plane then the direction of the ball is reversed. If the ball is outside the canvas in the x-plane then it’s position is reset to the centre of the canvas and the direction is reversed.

ball.rsuse piston_window::types::Color;
use piston_window::{ellipse, Context, G2d};
const BALL_COLOUR: Color = [1., 1., 1., 1.];
const RADIUS: f64 = 10.;
pub struct Ball {
pos_x: f64,
pos_y: f64,
vel_x: f64,
vel_y: f64,
width: f64,
height: f64,
}
impl Ball {
pub fn new(pos_x: f64, pos_y: f64,
canvas_dim: [f64; 2]) -> Ball {
Ball {
pos_x: pos_x,
pos_y: pos_y,
vel_x: 5.,
vel_y: 5.,
width: canvas_dim[0],
height: canvas_dim[1],
}
}

pub fn display(&mut self, con: &Context, g: &mut G2d) {
self.update();
ellipse(
BALL_COLOUR,
[self.pos_x, self.pos_y, RADIUS, RADIUS],
con.transform, g
);
}
fn update(&mut self) {
if (self.pos_y <= 0.) | (self.pos_y >= self.height) {
self.vel_y *= -1.;
}
if (self.pos_x <= 0.) | (self.pos_x >= self.width) {
self.vel_x *= -1.;
self.pos_x = self.width / 2.;
self.pos_y = self.height / 2.;
}
self.pos_x += self.vel_x;
self.pos_y += self.vel_y;
}
}

To add the ball to the canvas, the same approach is used as with the Paddle, except, in this case and extra argument is passed to the Ball::new function which contains an array of the width and height of the canvas.

Moving the player paddle

This may be a slight over complication on my part. However, it is the way I would implement it from base principles. In the main.rs file two additional variables are created, one to track if a key is held, which is a boolean. The other is a char type and will log the key_pressed as a single character (‘u’ for up, ‘d’ for down).

In order to assign the key_pressed, the piston_window event for press_args function is used to get the key. If the key matches either the up arrow or down arrow then key_pressed will be assigned ‘u’ or ‘d’ respectively. Additionally, the held variable is set to true.

main.rs...
while let Some(event) = window.next() {
if let Some(Button::Keyboard(key)) = event.press_args() {
held = true;
match key {
Key::Up => {
key_pressed = 'u';
}
Key::Down => {
key_pressed = 'd';
}
_ => {}
}
}
...
}

To track when a key is released, the event function release_args is monitored for. If it is triggered, then held is set to false and key_pressed set to ’n’ (representing none).

main.rs...
while let Some(event) = window.next() {
...
if let Some(Button::Keyboard(key)) = event.release_args() {
held = true;
key_pressed = 'n';
}
...
}

Having a way to track if the up or down arrows are pressed and held down in the main game loop, now in the Paddle struct there needs to be a function to update the paddle. This will be called update_player and will add or subtract from the players pos_y based on whether the paddle should move up or down the screen.

paddle.rsimpl Paddle {
...
pub fn update_player(&mut self, key, held) {
if (key == 'u') & (pressed) {
self.pos_y -= 5.;
}
if (key == 'd') & (pressed) {
self.pos_y += 5.;
}
}
}

The update_player function is called in main.rs just before the player.display function to ensure the paddle is moved before drawn on the canvas each frame refresh.

Moving the opponent paddle

Moving the opponent paddle will be based on the vertical position of the ball relative to the centre of the right hand paddle. If the ball is above the top quarter of the paddle, then the opponent will move up the canvas by subtracting from the pos_y. If the ball is below the bottom quarter of the paddle, then it moves down the canvas through addition to the pos_y.

paddle.rs
impl Paddle {
...
pub fn update_opponent(&mut self, ball_pos_y:f64) {
if self.is_user == false {
if self.pos_y + (3. * PADDLE_HEIGHT / 4.) < ball_pos_y {
self.pos_y += 3.;
} else if self.pos_y + PADDLE_HEIGHT / 4. < ball_pos_y }
self.pos_y -= 3.;
}
}
}
}

Restructuring to add in Game struct

In order to monitor if the ball is hit or not by the paddles, the first stage is to create a new struct called Game. This will be used to initialise and contain the existing aspects of the game, taking them out of the main.rs file. In addition to tracking the paddles and ball, the game will have two score attributes for the player and the opponent. Aside from the new function to initialise the Game struct, there is also a function to update_game, which will run the update and display functions from the ball and paddles.

game.rsuse piston_window::{Context, G2d};use crate::ball::Ball;
use crate::paddle::Paddle;
pub struct Game {
ball: Ball,
player_paddle: Paddle,
player_score: u16,
opponent_paddle: Paddle,
opponent_score: u16,
canvas_dim: [f64; 2],
}
impl Game {
pub fn new(canvas_dim: [f64; 2]) -> Game {
Game {
ball: Ball::new(canvas_dim[0] / 2., canvas_dim[1] / 2.,
canvas_dim),
player_paddle: Paddle::new(0., 200., true),
player_score: 0,
opponent_paddle: Paddle::new(canvas_dim[0] - 10.,
200., false),
opponent_score: 0,
canvas_dim: canvas_dim,
}
}
pub fn update_game(&mut self, con: &Context, g: &mut G2d,
key_pressed: char, held: bool) {
self.player_paddle.update_player(key_pressed, held);
self.player_paddle.display(&con, g);
self.opponent_paddle.update_opponent(self.ball.pos_y);
self.opponent_paddle.display(&con, g);
self.ball.display(&con, g);
self.hits_ball();
}
}

The main.rs file can be simplified now so that there is an initialisation for the Game struct and in the draw loop the update_game function is called.

main.rsmod game;
use game::Game;
...
fn main() {
...
let mut game: Game = Game::new([WIDTH as f64, HEIGHT as f64]);
...

window.draw_2d(&event, |context, graphics, _device| {
clear(BACKGROUND, graphics);
game.update_game(&context, graphics, key_pressed, held);
});
...
}

Rebounding the ball off the paddle

Within the Game struct created in the previous section, a function is needed to monitor if the ball hits the paddle when it reaches either end of the game canvas. The function (hits_ball) will look at when the position of the ball is within 10 pixels (width of the paddle) from the left or right edge of the screen. If this criteria is met, then then y position of the ball is compared to the appropriate paddle. If the ball’s y position is within the range covered by the paddle, then the ball’s x velocity is reversed. This ensures the ball moves back across the canvas if hit. When the ball reaches the end of the canvas and is missed by the paddles, then the score should be be updated to reflect this. Within the Ball struct the update function handles the resetting of the ball following a miss.

The function hits_ball is called in the Game::update_game function before the ball is updated to ensure the score can be updated before the direction of the ball is changed following a miss.

game.rs....
impl Game {
...
fn hits_ball(&mut self) {
if self.ball.pos_x <= 10. {
if (self.ball.pos_y >= self.player_paddle.pos_y)
& (self.ball.pos_y <= self.player_paddle.pos_y + 100.) {
self.ball.vel_x *= -1.;
}
} else if self.ball.pos_x <= 0. {
self.opponent_score += 1;
println!("Player: {} | Opponent: {}, self.player_score,
self.opponent_score);
}
if self.ball.pos_x >= self.canvas_dim[0] - 10. {
if (self.ball.pos_y >= self.opponent_paddle.pos_y)
& (self.ball.pos_y <= self.opponent_paddle.pos_y + 100.) {
self.ball.vel_x *= -1.;
}
} else if self.ball.pos_x >= self.canvas_dim[0] {
self.player_score += 1;
println!("Player: {} | Opponent: {}, self.player_score,
self.opponent_score);
}
}
...

Adding in the println! lines if the ball is missed allows the scores to be monitored in the console while the game is running. All that needs to be done now is to build and run the program using the below command in the terminal:

$cargo run
Pong game footage

Conclusions

This has been a challenging project for myself, from learning how to take inputs while using piston_window, to how to structure a game and the necessary logic behind the scenes. If you would like to run this for yourself, the code is available on my GitHub . Please let me know if you give this a go in the comments and like and follow me for more content.

Thank you very much for reading :)

--

--