This is the third post for a series of games I made to learn game development. It is time to breakout!
Scope
The scope of this project was to recreate the breakout game we all remember with basic sound effects.
Process
So this project started with creating the walls using StaticBody2D nodes on top, left, and right, followed by an Area2D
node for the bottom, similar to how we did for Pong.
Then I added a CharacterBody2D node for the paddle and tweaked the default script to restrict motion along the y axis, and
placed it in the lower half of the screen.
This was then followed by creating a similar CharacterBody2D for the ball. Then I added some logic to randomize the ball
launch angle. This is again very similar to pong, where I choose a random angle in (5π/4, -π/4) and simply shoot the ball
with an initial speed of 500.
The interesting part is creating the wall of bricks, which is done by the generate_wall_from_brick_scene() function below.
const BRICK_DIMENSIONS = Vector2(120, 40)
const BRICK_OFFSET = Vector2(
BRICK_DIMENSIONS.x / 2 + 60,
BRICK_DIMENSIONS.y / 2 + 20
)
func generate_wall_from_brick_scene() -> void:
var brick_scene: PackedScene = load("res://scenes/brick.tscn")
for brick_row in range(8):
for brick_col in range(15):
var brick = brick_scene.instantiate()
brick.row_number = brick_row
brick.add_to_group("bricks")
add_child(brick)
brick.position = Vector2(
(brick_col * BRICK_DIMENSIONS.x) + BRICK_OFFSET.x + 60,
(brick_row * BRICK_DIMENSIONS.y) + BRICK_OFFSET.y + 140
)
We first set the actual brick dimensions in world units. Next to make sure that a brick placed at (0,0)
visible we set an offset, without which only the bottom right quadrant of brick will be visible.
Now we load the brick.tscn scene, and run a double for loop to create a “table” of sorts. This table has 8 “cells” along the columns and 15 “cells” along the rows. Once the brick is instantiated we place it inside its “cell” starting row-wise first and then the columns. We also add it to a group bricks this is to perform operations like removal etc easily on these entities. Finally we add the brick as a child to the node tree, and place it properly. The +60 and +140 are not magic numbers, they are the location where the first bricks top left corner lies, in essence, it is where the wall starts. These values were chosen based on how other elements in the game (like walls) are arranged.
Then I did the usual polish, adding the menus etc. I tried to experiment with only have a single menu for both game start and game over by simply modifying the text for the buttons and repositioning UI elements. That approach was not really worth it I’ll say cause it complicated the game more than required.
Insights
- While creating the game I realized there are various variables that change over time. The obvious one being score but apart from that, the ball speed, and the paddle length. I found it interesting to explore the relationship between these variables. Let’s look at how I modified the paddle length:
# This snippet is incomplete. Its made to help you understand.
# Simply copy-pasting will not result to expected outcome.
func _process(delta: float) -> void:
if Global.is_ball_moving == true:
if ball_collider.is_in_group("bricks"):
paddle.scale.x = 4.0 - 3.0 * Global.player_score / 540.0
So what we do is, in the _process function, which runs once every frame. We first verify that the game has started and ball
is moving by confirming is_ball_moving global is true.
Then we record the collision info of the ball, and proceed only if ball hits an entity in the group “bricks”, in essence, ball hits the brick. Only when we know that the ball has hit a brick, we perform the rescaling ritual on the paddle.
We first normalize current player score by diving it with maximum score (which is 540), and then multiply it with 3 to scale it with respect to the current paddle size. The initial value of paddle.scale.x is 4.0 this comes from the actual sprite being scaled 4 times along x axis initial. But by as the number of brick is scaled close to its actual sprite size.
Note that by default Godot will scale x and y axis together, you’ll need to decouple them first for this to work. Also the values used are mostly chosen at random, based on the “game feel”.
func _physics_process(delta: float) -> void:
ball.velocity *= 1.01
ball.velocity = ball.velocity.limit_length(2000)
After trying various ways to increment ball velocity, I found that the easiest version is the most effective. This code is
very straightforward, every time _physics_process runs (usually 60 FPS), we increment the velocity by 1%. We need to also
put a ceiling on this velocity which I decided it to be 2000, again, based on “game feel”.
- Now, there’s one more trick I used I would like to mention. Its “Arranging the Sprite Sheet”!
var row_number: int = 0
func _ready() -> void:
sprite.region_rect = Rect2(0, 16 * row_number, 30, 10)
You see this tiny code, this is all we need to color our bricks in breakout. The row_number variable is set by the earlier
mentioned generate_wall_from_brick_scene(). It goes from 0 (top) to 7 (bottom).
The brick sprite sheet stacks 8 colored brick rows vertically, each of them is 16 pixels tall. We crop the sprite to show only the row that matches the current brick’s position. By being clever about making a sprite sheet, it is possible to reduce the amount of code to be written, this is something to keep in mind ;)
Changes
If I were to make the game again, here’s what I would do differently:
- Make separate menus for game start and game over.
Remarks
If you made it this far, Thank you so much for reading this.
Feel free to let me know your thoughts and checkout the links below.
Like always, more stuff is coming!
Links
Credits
The sfx for the game were from the asset pack created by Chequered Ink, many thanks for providing these for free use.