Pong clone in Ruby

Konstanty Koszewski
13 min readAug 16, 2023

--

Released in 1972 by Atari, Pong was a true cornerstone of the gaming industry. Its enormous commercial success encouraged more and more highly talented engineers. First into arcade machines, then to home gaming consoles and finally to personal computers. And the rest is the history. A history which we will bring back to life today.

This is a first chapter of my new series where we will recreate most famous arcade games of all the time. Mostly surprisingly easy to make and very rewarding for new programmers. In many cases such games require no more than 150 lines of code!

final code for this project could be found here

In the beginning…

First let’s take a look at the original game and its rules, as we want to be as precise as possible. Black screen is divided by half by a dotted white line. On the opposite sites there are two paddles moving up and down, controlled by either players or computer and numbers on the top, representing current score of the each site.

In our project we will use Ruby2D. A very simple yet efficient library for making 2d games. Although it’s not necessary I strongly encourage you to read its official documentation.

We start by making a new directory named pong with two files within: pong.rb and Gemfile. Gemfile will contain list of required gems while pong.rb is a core location for game’s logic. In gem file we require a call for Ruby2D gem fetched from a source of rubygems.com:

source 'https://rubygems.org'

gem 'ruby2d'

then in our console let’s type bundle to fetch gem:

pong % bundle
Using bundler 2.2.11
Using ruby2d 0.11.3
Bundle complete! 1 Gemfile dependency, 2 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.

Then let’s switch to pong.rb file and set up initial scaffold for our game. First we need to require Ruby2D and setup few constants defining initial parameters, such as height, width or base colors. Show is a method which displays a window on screen while program is running:

require 'ruby2d'

WIDTH = 800
HEIGHT = 600
BACKGROUND_COLOR = '#000000'
BASE_COLOR = '#FFFFFF'

set background: BACKGROUND_COLOR
set width: WIDTH, height: HEIGHT
set title: 'pong'

show

At this stage if everything went correct ruby pong.rb command should open up an empty window with the the size we defined and title. Hurray!

Descartes and static shapes

Our next step is to draw a static elements of the game. Ruby2D utilizes a notion of coordinate system where each point is described by x and y values. Unlike in basic math point 0,0 is located on the upper left part of the screen. In some cases those values will be splited into x1,x2 and y1,y2. Informing our program about start and end point of the shape.

With this knowledge we can define our shapes with ease. Dotted line dividing screen by half will be in fact a set of multiple smaller white lines on black background giving desired effect. Lines are drawn with simple Line.new object which requires x1, x2, y1, y2, width and color params. Create a loop which will generate a new Line object every 25 pixels exactly in the middle of the screen, 10 pixels long and 3 pixels hight. Wrap it into method and call right below:

def draw_dotted_line
(0..HEIGHT).step(25) do |i|
Line.new(x1: WIDTH/2 , x2: WIDTH/2 , y1: i , y2: i + 10, width: 3 , color: BASE_COLOR)
end
end

draw_dotted_line

If we restart our program we can see this:

With the same manner we can draw scores for our future players. By using Text.new objects and place it on the top side of the screen, close to the dividing line method:

def draw_players_score(player_score, opponent_score)
Text.new(player_score, x: (WIDTH / 2 ) - (WIDTH / 4 ), y: 30, style: 'bold', size: 80, color: BASE_COLOR )
Text.new(opponent_score, x: (WIDTH / 2 ) + (WIDTH / 4 ), y: 30, style: 'bold', size: 80, color: BASE_COLOR )
end

draw_players_score(0,0)

As we don’t have our players yet. We can call our new method with static values of zeros and restart program to check changes:

Now let’s focus on players and ball. Both of those object should be in constant motion. That means their coordinates will be changing during game. That is we need to introduce a new classes of Paddle and Ball:

class Paddle
def initialize(direction)
@x = direction == 'left' ? 30 : WIDTH - 30
@y = HEIGHT / 2
end

def draw
@shape = Rectangle.new(x: @x, y: @y, width: 7, height: 30, color: BASE_COLOR)
end
end

Paddle class takes an argument of string direction and decides whether it will be drawn on left or right side. Exactly 30 pixels away from the edge and right in the middle of the screen’s hight. Draw method will be responsible for recreating each player every time when called. Now we can create an instances of for both player and opponent and draw their paddles:

player   = Paddle.new('left')
opponent = Paddle.new('right')
player.draw
opponent.draw

Ball object is very similar. But with the Circle instead of Rectangle:

class Ball
def initialize
@x = WIDTH / 2
@y = HEIGHT / 2
end

def draw
@shape = Circle.new(x: @x, y: @y, radius: 5, color: BASE_COLOR)
end
end

ball = Ball.new
ball.draw

Let’s move!

Allright, now our game looks almost exactly like a good old Pong. However objects are fixed and nothing really going on. This is a perfect time to introduce you to the notion of the game loop. Every game uses frames. Coordinates of the objects are changing and entire screen is redrawn with a slightly different properties quick enough to create an illusion of constant move. So a game loop simply draws everything you can see with the average speed of 60 frames per second (FPS) by default. To start a game loop we simply need to wrap the properties we want to move within update method. Clear method inside the loop informs program that we want to wipe off previous frame before new one will be drawn. Now we can put our Paddle and ball objects into a game loop. As well as calls for methods drawing other shapes:

update do
clear

draw_dotted_line
draw_players_score(0, 0)

player.draw
opponent.draw
ball.draw
end

On the first glance nothing had changed. However this time our program clear and drawn again every visible object 60 times per second. With that feature we can implement moves, which are in fact just a changes of x,y coordinates in time. Let’s add two methods into our Paddle object: move_up and move_down. Those will change y coordinates and gives an illusion of moving our paddle up and down:

class Paddle
def initialize(direction)
@x = direction == 'left' ? 30 : WIDTH - 30
@y = HEIGHT / 2
end

def draw
@shape = Rectangle.new(x: @x, y: @y, width: 7, height: 30, color: BASE_COLOR)
end

def move_up
@y = (@y - 7)
end

def move_down
@y = (@y + 7)
end
end

We still need to handle an input from user’s keyboard with specific Ruby2D method on. This method takes :key_hold argument and accepts a block. Up and down arrows are the most intuitive keys for moving. Put this code right below the game loop:

on :key_held do |event|
if event.key == 'up'
player.move_up
elsif event.key == 'down'
player.move_down
end
end

Now you can control left paddle. With each frame when you hold up or down keys your paddle move by 7 pixels. However if you hold them for too long paddle would go off screen. We can fix it by modifying move_up and move_down methods. The most elegant way to do this is by confining possible values with clamp method which keeps y coordinate into boundaries of our screen size:

  def move_up
@y = (@y - 7).clamp(0, HEIGHT * 0.93)
end

def move_down
@y = (@y + 7).clamp(0, HEIGHT * 0.93)
end

With ability to move our paddle we can focus on putting ball in motion. Obviously it wont be controlled by player but moving itself. In the class constructor we need to place two additional variables responsible for the current ball’s direction. Than write new method move to change x,y coordinates with certain speed measured in pixels per frame:

class Ball
def initialize
@x = WIDTH / 2
@y = HEIGHT / 2
@x_direction = -1
@y_direction = -1
end

def draw
@shape = Circle.new(x: @x, y: @y, radius: 5, color: BASE_COLOR)
end

def move
@x += (5 * @x_direction)
@y += (2 * @y_direction)
end
end

And put our new method ball.move within game loop, right below everything.

Although our ball is moving it is still not responsible with other elements and quickly leave out of the screen. To change it we can first code how ball should behave if hits an edge of the screen with the following procedure:

  • keep track on ball coordinates within game loop
  • check if x value is equal or bigger/smaller then screen size (0 or WIDTH variable)
  • if so increase score of the opposite player and place ball into initial position

Game logic outside of the Ball class needs an access to the information about its current x value (by attr_reader method). Another class method over_map? will return boolean value telling if the ball hit the edge or not. Also we need to include method reset_position which put back both x and y values to the default. Additionally we want to sometimes change an x direction to let the ball move for both us and our computer opponent. Let’s say in 70% cases for us and 30% for him:

class Ball
attr_reader :x, :y

def initialize
@x = WIDTH / 2
@y = HEIGHT / 2
@x_direction = -1
@y_direction = -1
end

def draw
@shape = Circle.new(x: @x, y: @y, radius: 5, color: BASE_COLOR)
end

def move
@x += (5 * @x_direction)
@y += (2 * @y_direction)
end

def over_map?
@x >= WIDTH || @x <= 0
end

def reset_position
@x = WIDTH / 2
@y = HEIGHT / 2
@x_direction = rand < 0.3 ? 1 : -1
end
end

In the game loop we need to keep track on the ball position by calling ball.over_map method. If it returns true we will set another condition to figure out which side should get a score and finally reset ball. Furthermore we can finally add a @score variable to our Paddle class, access it to the public via attr_accessor method and replace static zeros with an actual scores in the draw_players_score method:

update do
clear

draw_dotted_line
draw_players_score(player.score, opponent.score)

player.draw
opponent.draw
ball.draw
ball.move

if ball.over_map?
if ball.x == 0
opponent.score += 1
elsif ball.x == WIDTH
player.score += 1
end

ball.reset_position
end
end
class Paddle
attr_accessor :score

def initialize(direction)
@x = direction == 'left' ? 30 : WIDTH - 30
@y = HEIGHT / 2
@score = 0
end
...
end

Another step is to track if the player had reflex quick enough to bounce back a ball with a paddle. It’s fairly easy. All we need to do is to implement additional method within Ball class to check if in current frame both paddle and ball shares the same coordinates. If so, ball should change its x direction and start moving toward the opposite direction. Luckily Ruby2D provides us very convenient method contains? which returns boolean if given point lies within current coordinates of the object:

class Ball
...
def hit_paddle?(player, opponent)
if player.shape.contains?(@x, @y) || opponent.shape.contains?(@x, @y)
@x_direction *= -1
end
end
...
end

A new hit_paddle? method should be placed in game loop, right above our previous our previous if ball.over_map? condition. Taking both player and opponent objects as an arguments:

 ball.hit_paddle?(player, opponent)

Also we need to update our Paddle class to provide a public information about the current coordinates of the shape variable:

class Paddle 
attr_accessor :score, :shape
...
end

Lastly let’s check if ball hit top or bottom edge of the screen the very same way as we did before. If so, we will simply change its y direction to bounce back. There is no need to go ‘public’ with this logic, we can keep it all within our Ball class. By adding a new private method hit_top_or_bottom? and call it every time when ball moves:

class Ball
attr_reader :x, :y

def initialize
@x = WIDTH / 2
@y = HEIGHT / 2
@x_direction = -1
@y_direction = -1
end

def draw
@shape = Circle.new(x: @x, y: @y, radius: 5, color: BASE_COLOR)
end

def move
hit_top_or_bottom?

@x += (5 * @x_direction)
@y += (2 * @y_direction)
end

def hit_paddle?(player, opponent)
if player.shape.contains?(@x, @y) || opponent.shape.contains?(@x, @y)
@x_direction *= -1
end
end

def over_map?
@x >= WIDTH || @x <= 0
end

def reset_position
@x = WIDTH / 2
@y = HEIGHT / 2
@x_direction = rand < 0.3 ? 1 : -1
end

private
def hit_top_or_bottom?
if @y >= HEIGHT || @y <= 0
@y_direction *= -1
end
end
end

At this point we’ve got fully responsible ball bouncing around and player’s paddle. But still someone is missing…

AI like it’s 1972!

A final step to make a game fully playable is to implement computer’s reactions. Our silicon opponent should keep a track on the ball and move its paddle to bounce it back to the other side. Idea is simple: our Paddle object should get an access to the current ball coordinates with newly created method. If ball goes up or down (changing y position) computer’s paddle should move to the same direction. Lets add a new track_ball method at the bottom of our Paddle class:

  def track_ball(ball_y)
if ball_y <= @y
move_up
elsif ball_y >= @y
move_down
end
end

Also within game loop lets call this method. Since one side is controlled by the computer, only opponent object requires tracking the ball:

opponent.track_ball(ball.y)

To make opponent moves look more human we should implement one last feature. Track a ball not on every frame, but every 30 frames since the ball was hit by a paddle. Window.frames method provides us number of frames passed since the game start. Before our game loop definition we should add another variable which counts frames:

last_hit_frame = 0

Then we can turn our ball.hit_paddle? method within game loop to update the variable above every time when player hit a ball:

 if ball.hit_paddle?(player, opponent)
last_hit_frame = Window.frames
end

and finally update track_ball method to prevent it from action unless 30 frames had passed:

  def track_ball(ball_y, last_hit_frame)
return unless last_hit_frame + 30 <= Window.frames

if ball_y <= @y
move_up
elsif ball_y >= @y
move_down
end
end

And that’s it! With only around 140 LOC we made a fully playable pong clone. A million dolar worth idea… if it’s still 1972 :)

Full code is written below:

require 'ruby2d'

WIDTH = 800
HEIGHT = 600
BACKGROUND_COLOR = '#000000'
BASE_COLOR = '#FFFFFF'

set background: BACKGROUND_COLOR
set width: WIDTH, height: HEIGHT
set title: 'pong'

def draw_dotted_line
(0..HEIGHT).step(25) do |i|
Line.new(x1: WIDTH/2 , x2: WIDTH/2 , y1: i , y2: i + 10, width: 3 , color: BASE_COLOR)
end
end

def draw_players_score(player_score, opponent_score)
Text.new(player_score, x: (WIDTH / 2 ) - (WIDTH / 4 ), y: 30, style: 'bold', size: 80, color: BASE_COLOR )
Text.new(opponent_score, x: (WIDTH / 2 ) + (WIDTH / 4 ), y: 30, style: 'bold', size: 80, color: BASE_COLOR )
end

class Paddle
attr_accessor :score, :shape

def initialize(direction)
@x = direction == 'left' ? 30 : WIDTH - 30
@y = HEIGHT / 2
@score = 0
end

def draw
@shape = Rectangle.new(x: @x, y: @y, width: 7, height: 30, color: BASE_COLOR)
end

def move_up
@y = (@y - 10).clamp(0, HEIGHT * 0.93)
end

def move_down
@y = (@y + 10).clamp(0, HEIGHT * 0.93)
end

def track_ball(ball_y, last_hit_frame)
return unless last_hit_frame + 30 <= Window.frames

if ball_y <= @y
move_up
elsif ball_y >= @y
move_down
end
end
end

class Ball
attr_reader :x, :y

def initialize
@x = WIDTH / 2
@y = HEIGHT / 2
@x_direction = -1
@y_direction = -1
end

def draw
@shape = Circle.new(x: @x, y: @y, radius: 5, color: BASE_COLOR)
end

def move
hit_top_or_bottom?

@x += (5 * @x_direction)
@y += (2 * @y_direction)
end

def hit_paddle?(player, opponent)
if player.shape.contains?(@x, @y) || opponent.shape.contains?(@x, @y)
@x_direction *= -1
return true
end
end

def over_map?
@x >= WIDTH || @x <= 0
end

def reset_position
@x = WIDTH / 2
@y = HEIGHT / 2
@x_direction = rand < 0.3 ? 1 : -1
end

private
def hit_top_or_bottom?
if @y >= HEIGHT || @y <= 0
@y_direction *= -1
end
end
end

player = Paddle.new('left')
opponent = Paddle.new('right')
ball = Ball.new
last_hit_frame = 0

update do
clear

draw_dotted_line
draw_players_score(player.score, opponent.score)

player.draw
opponent.draw
ball.draw
ball.move

opponent.track_ball(ball.y, last_hit_frame)

if ball.hit_paddle?(player, opponent)
last_hit_frame = Window.frames
end

if ball.over_map?
if ball.x == 0
opponent.score += 1
elsif ball.x == WIDTH
player.score += 1
end

ball.reset_position
end
end

on :key_held do |event|
if event.key == 'up'
player.move_up
elsif event.key == 'down'
player.move_down
end
end

show

There is still so much to improve

Even if our pong clone is done there are still many ways od adjust a gameplay for you:

  • improve computer AI: at this point opposite paddle is usually too strong to beat. Also its moves looks still quite unhuman. hint: you can implement more complex calculations based on trigonometry.
  • play with paddles and ball speed or set winning conditions. With game over screen after one site will reach certain amount of points.
  • replace computer with second player. To do this you just need to delete tracking ball feature and give other player ability to manipulate right paddle with other keys.
  • add some sounds with Ruby2D library. Documentation could be found here
  • make it multiplayer. It is perfectly possible to implement API module(e.g. with httparty) and send your coordinates to the other player via small server.

--

--