⚠️ Beta State
PyBevy is in an early and experimental stage. The API is incomplete, subject to breaking changes without notice, and you should expect bugs. Many features are still under development.
Capstone Project: Breakout
A simple Breakout clone combining all 2D concepts.
Introduction
Let's build a small game! This Breakout clone will use everything we've learned in the 2D track: sprites, transforms, input, UI, and simple collision detection. This is a capstone project to solidify your understanding.
from pybevy.prelude import *
from math import pi
# --- Constants ---
PADDLE_SIZE = Vec2(120.0, 20.0)
BALL_DIAMETER = 30.0
BALL_SPEED = 400.0
INITIAL_BALL_DIRECTION = Vec2(0.5, -0.5)
WALL_THICKNESS = 10.0
LEFT_WALL = -450.0
RIGHT_WALL = 450.0
BOTTOM_WALL = -300.0
TOP_WALL = 300.0
BRICK_SIZE = Vec2(100.0, 30.0)
GAP_BETWEEN_BRICKS = 5.0
# --- Components ---
@component
class Paddle(Component): pass
@component
class Ball(Component):
def __init__(self, velocity: Vec2):
self.velocity = velocity
@component
class Brick(Component): pass
@component
class Collider(Component): pass
@component
class Scoreboard(Component): pass
# --- Resources ---
@resource
class Score(Resource):
def __init__(self, value: int):
self.value = valueSetup
We'll spawn the camera, paddle, ball, walls, bricks, and scoreboard UI. This sets up the initial state of our game.
def setup(commands: Commands):
commands.spawn(Camera2d())
commands.insert_resource(Score(0))
# Paddle
commands.spawn(
Paddle(),
Collider(),
Sprite.from_color(Color.srgb(0.3, 0.3, 0.7), (PADDLE_SIZE.x, PADDLE_SIZE.y)),
Transform.from_xyz(0.0, BOTTOM_WALL + 60.0, 0.0),
)
# Ball
commands.spawn(
Ball(velocity=INITIAL_BALL_DIRECTION.normalize() * BALL_SPEED),
Sprite.from_color(Color.srgb(1.0, 0.5, 0.5), (BALL_DIAMETER, BALL_DIAMETER)),
Transform.from_xyz(0.0, -50.0, 1.0),
)
# Walls
commands.spawn(Collider(), Sprite.from_color(Color.srgb(0.8, 0.8, 0.8), (WALL_THICKNESS, TOP_WALL - BOTTOM_WALL)), Transform.from_xyz(LEFT_WALL, 0.0,
0.0))
commands.spawn(Collider(), Sprite.from_color(Color.srgb(0.8, 0.8, 0.8), (WALL_THICKNESS, TOP_WALL - BOTTOM_WALL)), Transform.from_xyz(RIGHT_WALL, 0.0,
0.0))
commands.spawn(Collider(), Sprite.from_color(Color.srgb(0.8, 0.8, 0.8), (RIGHT_WALL - LEFT_WALL, WALL_THICKNESS)), Transform.from_xyz(0.0, TOP_WALL,
0.0))
# Bricks
for row in range(4):
for col in range(7):
brick_pos = Vec2(
LEFT_WALL + 100.0 + float(col) * (BRICK_SIZE.x + GAP_BETWEEN_BRICKS),
TOP_WALL - 100.0 - float(row) * (BRICK_SIZE.y + GAP_BETWEEN_BRICKS),
)
commands.spawn(
Brick(),
Collider(),
Sprite.from_color(Color.srgb(0.5, 0.5, 1.0), (BRICK_SIZE.x, BRICK_SIZE.y)),
Transform.from_translation(brick_pos.extend(0.0)),
)
# Scoreboard
score_node = Node()
score_node.position_type = 1 # Absolute
score_node.top = 5.0
score_node.left = 5.0
commands.spawn(
Scoreboard(),
Text("Score: 0"),
TextFont(font_size=40.0),
score_node,
)Game Logic Systems
These systems handle paddle movement, ball movement, and collision detection.
def move_paddle(
keyboard_input: Res[ButtonInput],
time: Res[Time],
query: Query[Mut[Transform], With[Paddle]],
):
paddle_speed = 500.0
for transform in query:
direction = 0.0
if keyboard_input.pressed(KeyCode.ArrowLeft):
direction -= 1.0
if keyboard_input.pressed(KeyCode.ArrowRight):
direction += 1.0
new_pos_x = transform.translation.x + direction * paddle_speed * time.delta_secs()
half_paddle_size = PADDLE_SIZE.x / 2.0
left_bound = LEFT_WALL + WALL_THICKNESS / 2.0 + half_paddle_size
right_bound = RIGHT_WALL - WALL_THICKNESS / 2.0 - half_paddle_size
transform.translation.x = max(left_bound, min(right_bound, new_pos_x))
def apply_velocity(time: Res[Time], query: Query[tuple[Mut[Transform], Mut[Ball]]]):
for transform, ball in query:
transform.translation.x += ball.velocity.x * time.delta_secs()
transform.translation.y += ball.velocity.y * time.delta_secs()
def update_scoreboard(score: Res[Score], query: Query[Mut[Text], With[Scoreboard]]):
for text in query:
text.content = f"Score: {score.value}"
def check_for_collisions(
commands: Commands,
score: ResMut[Score],
ball_query: Query[tuple[Mut[Ball], Transform]],
collider_query: Query[Transform, With[Collider]],
):
# Collision detection between the ball and all colliders.
# For each collision, reflect the ball velocity.
for ball, ball_transform in ball_query:
ball_pos = ball_transform.translation
ball_size = ball_transform.scale.truncate()
for collider_transform in collider_query:
collider_pos = collider_transform.translation
collider_size = collider_transform.scale.truncate()
# Simple AABB collision check
collision = collide(ball_pos, ball_size, collider_pos, collider_size)
if collision is not None:
score.value += 1
reflect_x = (collision == Collision.Left and ball.velocity.x > 0.0) or \
(collision == Collision.Right and ball.velocity.x < 0.0)
reflect_y = (collision == Collision.Top and ball.velocity.y < 0.0) or \
(collision == Collision.Bottom and ball.velocity.y > 0.0)
if reflect_x:
ball.velocity.x = -ball.velocity.x
if reflect_y:
ball.velocity.y = -ball.velocity.y
return # Only handle one collision per frame
class Collision:
Left = "Left"
Right = "Right"
Top = "Top"
Bottom = "Bottom"
def collide(a_pos, a_size, b_pos, b_size):
# AABB collision detection
half_a = Vec2(max(a_size.x, BALL_DIAMETER) / 2.0, max(a_size.y, BALL_DIAMETER) / 2.0)
half_b = Vec2(max(b_size.x, 1.0) / 2.0, max(b_size.y, 1.0) / 2.0)
diff_x = a_pos.x - b_pos.x
diff_y = a_pos.y - b_pos.y
overlap_x = half_a.x + half_b.x - abs(diff_x)
overlap_y = half_a.y + half_b.y - abs(diff_y)
if overlap_x <= 0.0 or overlap_y <= 0.0:
return None
# Determine collision side based on smallest overlap
if overlap_x < overlap_y:
if diff_x > 0.0:
return Collision.Left
else:
return Collision.Right
else:
if diff_y > 0.0:
return Collision.Bottom
else:
return Collision.TopRunning the Game
@entrypoint
def main(app: App) -> App:
return (
app
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(Update, (move_paddle, apply_velocity, update_scoreboard, check_for_collisions))
)
if __name__ == "__main__":
main().run()