The 20 Games Challenge I: Pong

This is the first entry in a series of posts pertaining to the 20 Games Challenge - please see the “20 Games Challenge” tag below for future posts

Intro

I have always been interested in game development, but never really completed any ambitious projects in the field, and for a long time couldn’t see myself as a game developer in any capacity.

However, my perspective on the field changed throughout college; playing more indie games made me appreciate the unique visions and development processes of hundreds of small game developers, and getting back into art made me want to apply my skills in another field that I greatly enjoy: programming. I now knew I wanted to give game development another, more committed, shot.

Introducing the 20 Games Challenge

At this point, I had picked Godot Engine to start my work in developing games. I already had some experience in Unity, but I wasn’t looking to work in an exclusively 3D environment, and I heard many great things about the former, particularly its ease of use and distinction as an open source project.

After pondering for a long while on what I could begin with, I eventually stumbled upon the 20 Games Challenge. The idea of the “challenge” was that I would gain more confidence in game development as I recreated popular games from scratch - from simple titles like Pong, to more advanced ones like Minecraft.

The challenge is simply a syllabus of games to complete, not a guide on how to make each game, so I was required to do my own research on topics/methods I didn’t know about.

Hour 1: Setup, Paddles

Even if it’s a relatively small game project, Pong still has a number of components I need to consider, from sprites to physics to collision, so, as is the case with other projects I’ve worked on, I wanted to start with the big picture, then work on more specific functionality.

Display, Controls

The first thing I considered was the size of the game window. Godot projects default to a 16:9 width to height aspect ratio, so I decided to stick with that.

After that, I set up the input map for the game. I added four controls for now, the up and down paddle movement bindings for each of the two players. With player 1 using the left paddle, and player 2 using the right, I set player 1’s movement keys to W/S (for up and down, respectively), and player 2’s to the up and down arrow keys.

Root Node, Color Background

In Godot, nodes are used as the building blocks of a game. Much like other engines like Unity, they have their own properties (such as textures and physics values) and are arranged in a tree-like hierarchy.

Scenes are game “objects” including characters and the game environment. These contain nodes, as well as other scenes that are considered nodes, known as instances.

I initially set up a Main scene, which serves as the root of the node tree and contains every game object. This has no properties to change, it’s just for grouping purposes.

I subsequently added the first child node to the tree - a ColorRect used for the solid black backdrop. Handily, Godot has anchors which can be used to automatically attach objects to specific positions and/or resize them based on certain presets. For my use case, I selected “Full Rect” to cover the screen.

Paddle Setup

Up next, I wanted to add in the player controlled paddles. There are, of course, two in Pong, but to minimize duplicate code and make modifications easier, I only created one paddle scene.

It took me a while to figure out what node to use to display them; I didn’t want to use an image since I wanted to be able to easily resize the paddles if necessary. I eventually settled on what felt like an odd solution: using a Sprite2D node, but using an ImageTexture mapped to a single colored pixel. That way, I’d render them like shapes, and the single color would stretch to the size of the paddle. I had player 1’s paddle be blue, and player 2’s red.

Despite both paddles being instances of the same scene, meaning their child nodes (i.e. the Sprite2D) are the same, their properties - including their textures - are independent of each other. This means I could assign each paddle a different color texture.

The main scene so far. Of note, the bottom-most node is at the top

Paddle Positioning

I wanted to keep the positioning logic of all the game objects based on the screen size, not specific hard-coded numbers. As such I did not manually position them in the 2D editor, I decided to take the programmatic approach.

@export var player = 1
const X_PADDING = 50

func reset_position():
	position.x = scale.x / 2 + ((get_viewport_rect().size.x - scale.x) * (player - 1))
	position.x += X_PADDING * (1 if player == 1 else -1)
		
	position.y = get_viewport_rect().size.y / 2

For the x position, I set it based on the width of the paddle (halved so it will at least just barely be fully on screen), then added a constant padding to it so they are more visible. player is an exported variable1 that is either set to 1 or 2 depending on the instance, so I can have player 1’s paddle on the left and player 2’s on the right.

Paddle Movement

With the script file already created, I coded the controls for moving each paddle using the input maps I defined earlier. What’s great about Godot’s implementation is that the mappings, in code, are referred to as strings by their names. This snippet listens for the events defined in the project’s input map (so in this case, if ‘p1_up’ triggers when I press W, it triggers any code under that if statement).

func _physics_process(delta: float) -> void:
	if Input.is_action_pressed("p" + str(player) + "_up"):
		position.y -= VELOCITY
	if Input.is_action_pressed("p" + str(player) + "_down"):
		position.y += VELOCITY

Initially, I put this code in the _process() function, which runs every frame. The issue I found was that on displays with a higher framerate, this function runs more often every second, affecting input and physics based processes. This is where the _physics_process() function came into play, as this runs at a consistent 60 fps regardless of display settings.

Hour 2: Ball, Collision

Sprite2D to Area2D

Upon looking into what to do to start working on implementing the ball object, I realized having the paddle’s Sprite2D node be the root node wasn’t the best idea, since that node is not capable of collision detection, of course essential for hitting the ball.

There are two main nodes used for detecting collisions of an object: areas and bodies. Since the latter is intended for objects affected by physics (i.e. gravity), using areas for everything made sense, since physics are not a focal point of this game.

I changed the root node of the Paddle scene to an Area2D, and made the Sprite2D object holding the texture a child of it.

For instances, only the properties of their root node can be changed in the editor’s inspector, so in this case, I could only change each paddle’s Area2D node properties independently. Since the Sprite2D is a child of the Area2D, its properties are not accessible per instance, and can only be changed for the entire Paddle scene (affecting every Paddle instance in the game).

That meant I now had to programmatically assign the image texture to the sprite for each paddle.

func _ready() -> void:
	var image = Image.new()
	if player == 1:
		image.load("res://textures/blue.png")
	else:
		image.load("res://textures/red.png")
	$Sprite.texture = ImageTexture.create_from_image(image)

	reset_position()

Clamping Movement

There were a couple other tasks I wanted to complete as well. First, though I could move both paddles fine, I could move them off screen too.

Currently my code directly manipulated the paddle’s position field, but I changed the movement to use the Vector2 object instead, since this object has a very useful clamp function. Essentially, I could store the x and y velocities in a vector2 and change the paddle position using vector arithmetic. The clamp function lays out a limit on the values for each component, such that if the minimum or maximum thresholds are exceeded, that component’s value gets set back to the limit.

The new code to move the paddles was:

func _physics_process(delta: float) -> void:
	var velocity = Vector2.ZERO
	
	if Input.is_action_pressed("p" + str(player) + "_up"):
		velocity.y -= VELOCITY
	if Input.is_action_pressed("p" + str(player) + "_down"):
		velocity.y += VELOCITY
		
	position += velocity * delta

with delta being the elapsed time since the last physics frame.

In this simplified version of my implementation, I clamp just the y component. If the player tries to move their paddle past y = 0 (so up past the top of the screen), the y position gets set back to 0. Same goes for moving it down past the bottom.

position.y = clamp(
    position.y, 
    0, 
    get_viewport_rect().size.y
)

I had to add some vector arithmetic since this still allowed about half of the paddle to leave the screen. Note that for the scale, I refer to that of the now-child node Sprite, since that’s where I defined the scale changes for the object.

position.y = clamp(
    position.y, 
    0 + ($Sprite.scale.y / 2), 
    get_viewport_rect().size.y - ($Sprite.scale.y / 2)
)

Drawing the Divider

Next, I wanted to draw a dotted divider down the middle of the screen. Instead of using many nodes, or instances of a scene, I just used Canvas2D functions in the Background node to accomplish this. The _draw() function is called on launch and whenever manually requested to redraw Canvas related items, so I put the code there.

func _draw():
	var half_width = get_viewport_rect().size.x / 2
	var y = 0
	
	while y < get_viewport_rect().size.y:
		draw_line(
            Vector2(half_width, y), 
            Vector2(half_width, y + 10), 
            Color(190, 190, 190, 0.3), 5.0
        )
		y += 20

I used RGBA with only a slightly opaque grey (instead of a template color) to distinguish what would be the ball from this dotted line.

Ball Setup

I set up the initial position of the ball in a similar way as I did for the paddles - to the center of the screen via code in the _ready() function.

position.x = get_viewport_rect().size.x / 2
position.y = get_viewport_rect().size.y / 2

Also similar to the paddle script, movement for the ball was implemented using Vector2D structures. This time, both the x and y components would be used so I defined separate xvel and yvel velocity variables for it. I also defined a ball_velocity variable related to the overall velocity; think of the xvel and yvel as the opposite and adjacent sides of a triangle, and the ball_velocity as the hypotenuse.

Ball collisions

The simple collision to handle was on the top and bottom walls. Along with constantly moving the ball every frame, I inverted the ball’s yvel if that happened:

if position.y < 0 or position.y > get_viewport_rect().size.y:
    yvel *= -1

velocity.x += xvel
velocity.y += yvel
position += velocity * delta

The harder one to work with was against the paddles. Here, the angle of the ball coming off the collision would depend on where on the paddle it hit; the closer to an edge of it, the more extreme the angle would be. Conversely, if it hit the exact middle of the paddle, it would reflect perpendicular to it.

This thread on the game development stack exchange provided very valuable insight on how to accomplish this through some math. Essentially it would involve a relative intersection, aka how far from the center of the paddle the ball hit, and a normalized version which is that value crushed to a -1.0 to 1.0 range - this is multiplied to a hard-coded maximum bounce angle to determine the exact angle the ball should bounce at.

A quick diagram I drew up to demonstrate the components of my implementation of bounce angle calculation. A relative instersection of 0 is at the exact center, 18.75 halfway to the top, -37.5 at the very bottom

And in code:

func _on_area_entered(area: Area2D) -> void:
	var sprite = area.get_node_or_null("Sprite")
	if sprite:
		var relative_intersect = (area.position.y) - position.y
		
		var normalized_relative_intersect = (relative_intersect / (sprite.scale.y / 2))
		var bounce_angle = normalized_relative_intersect * MAX_BOUNCE_ANGLE

Finally to determine the xvel and yvel given the bounce angle, we just use some trigonometry. I also had to add in a call to the sign function3 to make sure the ball bounced back in the right direction for both paddles. I also increased how fast the ball went after hitting to increase the challenge and prevent tediously long games.

xvel = ball_velocity * cos(bounce_angle) * sign(position.x - area.position.x)
yvel = ball_velocity * -sin(bounce_angle)

ball_velocity += 25

Hour 3: Scoring

Score Display

There was still a core part of the ball scripting missing: handling scores when it hits the left or right walls.

Firstly, I created two Labels as children of the Background node. These simply display text which can be manipulated in the editor or in code.

I wanted the text to be aligned centrally. However, unlike in applications like Word or Google Docs, text alignment is relative to the size of the label’s textbox, so I had to resize each label’s horizontal scale to span half of the screen width (since each one would be on either side of the divider).

$Score1.size.x = get_viewport_rect().size.x / 2
$Score2.size.x = get_viewport_rect().size.x / 2

The player 1 score label was in the right place already from just the alignment; I only had to move the player 2 label to the other side.

$Score2.position.x = get_viewport_rect().size.x * 0.5

Keeping Count

What good would score text be if it didn’t actually show each player’s score, after all?

Back in the ball script, I added in a custom signal score, which I could trigger when the ball hit either the left or right wall. When I “emit” this signal, any other scripts which are set to “receive” the signal will be signalled to execute additional code.

To begin, I define the custom signal in the header of the script:

signal score

And when the ball scores, I broadcast the signal, passing in a parameter representing the player number of whoever scored:

if position.x < 0 or position.x > get_viewport_rect().size.x:
    score.emit(2 if position.x < 0 else 1)

From here, in the Godot editor, I connected this signal to a new script in the Main node (the root of the node tree), which would have access to all child nodes and their properties. This created a new function _on_ball_score(player) which would fire every time the score signal was emitted in the ball script.

In the main script, I initialized two player score variables to 0. I also defined a serve_direction, which corresponds to which player would receive the ball when it is served (at the start of the game or after a score).

Within the ball score function, I of course updated the score and serve direction (to whoever “lost” that round), updated the score labels, set the paddles back to their original positions, and re-served the ball to the loser.

func _on_ball_score(player: int) -> void:
	if player == 1:
		p1_score += 1
		serve_direction = 2
	elif player == 2:
		p2_score += 1
		serve_direction = 1
		
	$Background.update_score(p1_score, p2_score)
	
	$Player1.reset_position()
	$Player2.reset_position()

	$Ball.serve(serve_direction)
func serve(direction: int) -> void:
	position.x = get_viewport_rect().size.x / 2
	position.y = get_viewport_rect().size.y / 2
	
	ball_velocity = BASE_VELOCITY
	xvel = ball_velocity * (-1 if direction == 1 else 1)
	yvel = 0

For encapsulation, I handled the score text update functionality within the background.gd script.

func update_score(p1, p2):
	$Score1.text = str(p1)
	$Score2.text = str(p2)

Finally, in the main script’s _ready() function, I immediately called $Ball.serve(1) to send the ball to the first player at the start of every game.

Now we’re able to play pong with two players, while keeping track of score!

Hour 4: Sounds, AI

Up to this point, I worked on the past three hours of this project in April 2025. Due to exams and other obligations, I had to put the project on hold, but at the time of writing, it was now May 2025, and I decided to finish the project and move onto the next games in the curriculum.

There was a lot I could do at this point, but to not stick to this project forever and continually work on my game dev skills, I decided to constrain myself to about two more hours of work. My main goals were to implement sounds, a working “fair” AI, a menu, and a CRT shader.

Sound effects

On the surface level, setting up an audio player was pretty easy - just use an AudioStreamPlayer node (which I named ‘SFX’), which is suited for playing background music/effects, UI sounds, or other sounds that do not have a “source” location. Pong, being a simple game, has sounds for two events: the ball hitting something, and a score.

I downloaded a couple Pong sounds from Universal Soundbank, and utilized Audacity to trim them, clear out their background noise, and lower their volume.

For the collision sound, I decided to have it play at two different pitches if it hits a paddle vs. a wall, which I accomplished through pitch shifting in Audacity. Sadly it sounds pretty “compressed” and a bit nasty overall, so going forward, I acknowledged the best way to go about it would be to use synthesizers on my own to produce original, clean sound effects.

While the ‘SFX’ node is a child of the ‘Main’ node, some of the SFX functionality (specifically for collisions) is called from the Ball object. To accomplish this, in the Ball’s _ready() function, I called get_node with a relative path to obtain a reference to the ‘SFX’ node, pretty easy since I already knew where it was in the node tree.

func _ready():
	sfx = get_node('../SFX')

And for any calls, I simply changed its stream property, and called the play() function.

sfx.stream = load("res://audio/hit_paddle.mp3")
sfx.play()

AI

While the game was playable with 2 players at this point, I wanted to add in a 1 player mode, which would require an AI controller. Starting off simple, I added a boolean toggle to the Player script, set to ’true’ if the paddle is AI controlled. However, I only want player 2 to be able to be AI controlled, so I wrote an assert statement that would crash the game if player 1 was set as AI.

assert(player == 1 and not is_ai or player == 2, "Only player 2 can be an AI")

At its core, the AI should “know” the current location of the ball and understand in what direction it’s moving, just as a human would. In a similar manner to above, I called get_node('../Ball') in the Player script’s _ready() function to have a reference to the Ball object handy.

To make sure my implementation of AI works at a very basic level, I simply made it so the y-position of the player 2 paddle always matched the y-position of the ball. I wrote this logic in the _physics_process function (which also has input control handling) so the y-position clamping still works.

Obviously, this is a pretty bad AI, as it’s impossible to beat because it plays the game perfectly.

So my next task was to make the AI “fair,” or imperfect. I found a very handy post on Code Incomplete which discussed exactly this matter. The core idea was to have the AI:

  • Predict where the ball would be when it ends up near its paddle, as a human would try and do
  • Have some sort of reaction time before predicting where the ball will end up
  • Have some sort of “error,” where the AI’s paddle doesn’t hit the ball exactly at center every time.

For a later milestone in the development process, I declared an array which stored four difficulty levels for the AI, with faster reaction times and increased paddle movement speed.

const DIFFICULTIES = [
	{ "reaction": 1.5, "speed": VELOCITY / 1.5 }, # easy
	{ "reaction": 1.0, "speed": VELOCITY / 1.25 }, # medium
	{ "reaction": 0.5, "speed": VELOCITY }, # hard
	{ "reaction": 0.1, "speed": VELOCITY * 1.25 }, # very hard
]

My first task was to set up the reaction timer for the AI. This involved me creating an ‘AIResponseTimer’ node, which as you might have guessed, is a one-shot Timer, meaning it doesn’t auto-restart when it expires. Essentially, when the ball hits the human’s paddle, the timer will start, run for between 0.1 and 1.5 seconds depending on difficulty, then make its first prediction of where the ball will end up. Additionally, it will re-do its prediction if the ball hits a wall after the initial timer expires.

For the prediction, I just had the AI take in the ball’s x/y velocities, and predict how long it would take to reach its paddle’s x-position. Then, I predicted the y-position using that time. Finally, to make sure the paddle doesn’t always hit the center, I set up a RNG and added a random number based on the size of the paddle.

Every time the AI hits the ball or the game serves it again, its prediction is cleared, and it waits until the human hits the ball again.

func _on_ai_response_timer_timeout() -> void:
	if ball.xvel > 0 and is_ai:
		predict_ball_location()

func predict_ball_location():
	var time_to_paddle = abs(position.x - ball.position.x) / abs(ball.xvel)

	predicted_ball_y = ball.position.y + (ball.yvel * time_to_paddle)
	
	var rng = RandomNumberGenerator.new()
	predicted_ball_y += rng.randf_range(
		-$Sprite.scale.y / 2.1, 
		$Sprite.scale.y / 2.1
	)

Finally, in _physics_process, I added in the logic for the AI to move to hit the ball once it has a prediction in its mind. To prevent the AI’s paddle from becoming “shakey” once it reaches around where it thinks the ball will hit, I introduced an error margin of 10 pixels, so it stops moving if it gets within 10px of where the ball should end up.

func _physics_process(delta: float) -> void:
	var velocity = Vector2.ZERO # movement vector
	
	if not is_ai:
		if Input.is_action_pressed("p" + str(player) + "_up"):
			velocity.y -= VELOCITY
		if Input.is_action_pressed("p" + str(player) + "_down"):
			velocity.y += VELOCITY
	
	if is_ai:
		if ball.xvel < 0:
			return
			
		if predicted_ball_y != -1 and abs(predicted_ball_y - position.y) > 10:
			# move to prediction
			if predicted_ball_y < position.y: # move down
				velocity.y -= DIFFICULTIES[difficulty_level]['speed']
			else: # move up
				velocity.y += DIFFICULTIES[difficulty_level]['speed']

This is much better and a lot more fun to play against.

Hour 5: Menu, CRT Effect

OK great, the main game loop is now complete. To add some polish, my aims now were to add in a menu, a “serve” timer (so the player isn’t jumpscared by the ball launching towards them at the start of a round), and for nice effect, a CRT filter.

I wanted to add four components to the start screen:

  • A title, which is a Label object
  • A start button, which is just a Button
  • A difficulty dropdown, an OptionButton
  • A toggle for player 2’s AI status, a CheckButton

All of these were pretty easy to implement, setup in the inspector, and lay out in the 2D view. One of the most interesting things I found out was that in the inspector, when changing a node’s position or scale, you can use arithmetic to multiply a number and automatically set its result to the property’s value! So I can type in ‘312*2’ for a node’s x-position, and upon hitting ENTER, its x-position is set to 624.

When the start button was clicked, I made it so its pressed() signal emitted, which in turn hid every menu element, and fired a custom start_game signal sent to various components under the Main node.

Essentially, I had to move most of what was in all of my _ready() functions at this time to the functions fired when start_game is emitted. For Main, that included showing every gameplay element (scores, paddles, the ball) and serving the ball to player 1. For the player, that included setting the AI difficulty, if it was enabled in the menu.

I also had to move the dotted line drawing script to a new node under ‘Background,’ since drawing functions can only be called in the _draw() method, and that always runs on launch. I didn’t want the dotted lines to be visible under the menu, so I hid that new child node, and made it visible once the game started.

Round Timer

As per above, I didn’t want the players to be startled when the ball is served in their direction, so I wanted to add in a timer which gave them a couple seconds to prepare and let them acknowledge a new round is about to start. I simply added a timer called ‘ServeCountdown’ under the Main node. With a wait time of two seconds, it initially shows a “Get ready…” label; when the timer expires, the label is hidden and the ball is served like before.

CRT

Finally, I wanted to add in a CRT effect just so the game looked more retro. I found out shaders are typically used to accomplish visual effects like this, so I simply used the code from this shader preset on Godot Shaders and applied it to a new ColorRect layered on top of every other game object. Looks nice, and isn’t too distracting.

The topic of creating shaders is a very complex one for me at this stage, but hopefully I’ll learn more about how they work in detail.

Final thoughts

I still have a long way to go with game development, but I’m still very proud of the start I made through working on this project. I gained a lot of insight on how to plan out developing a game, how to organize code and game objects, and how game components interact with each other.

Some of the components I gained familiarity with include:

  • Area2D (used for collision detection, representing an object’s “body”)
  • Sprite2D (used for displaying the object itself in a multitude of ways)
  • CollisionShape2D (needed if you want to actually have collisions)
  • ColorRect
  • Timer
  • AudioStreamPlayer
  • Various Canvas nodes, such as Label, Button, etc.

Encapsulation in scripting became very important especially near the end, as the scripts began to interact with each other much more (i.e. main.gd controlling the game as a whole, player.gd needing to know where the Ball is, etc.). Going forward, I will make sure to consider that earlier in the process, so I not only end up with more organized code, but also so I don’t have to go through the headache of refactoring everything deep into the development process.

As for things I’d like to do eventually, I want to learn UI styling to make buttons, text, etc. not look so generic, and eventually dive into shader creation. But that’s for the long haul, between now and then I have a long way to go in terms of actually making complete games.


  1. This means it can be modified from the inspector, overriding the default value written in code ↩︎

  2. Of course, I’d only ever use the y component for the paddles ↩︎

  3. Returns -1 if the input is less than 0, else 1 ↩︎