⚠️ 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 pybevy.ui import Node, Text, PositionType, Val
from pybevy.text import TextFont
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(color=Color.srgb(0.3, 0.3, 0.7), custom_size=PADDLE_SIZE),
Transform.from_xyz(0.0, BOTTOM_WALL + 60.0, 0.0),
)
# Ball
commands.spawn(
Ball(velocity=INITIAL_BALL_DIRECTION.normalize() * BALL_SPEED),
Sprite(color=Color.srgb(1.0, 0.5, 0.5), custom_size=Vec2.splat(BALL_DIAMETER)),
Transform.from_xyz(0.0, -50.0, 1.0),
)
# Walls
commands.spawn(Collider(), Sprite(color=Color.srgb(0.8, 0.8, 0.8), custom_size=Vec2(WALL_THICKNESS, TOP_WALL - BOTTOM_WALL)), Transform.from_xyz(LEFT_WALL, 0.0,
0.0))
commands.spawn(Collider(), Sprite(color=Color.srgb(0.8, 0.8, 0.8), custom_size=Vec2(WALL_THICKNESS, TOP_WALL - BOTTOM_WALL)), Transform.from_xyz(RIGHT_WALL, 0.0,
0.0))
commands.spawn(Collider(), Sprite(color=Color.srgb(0.8, 0.8, 0.8), custom_size=Vec2(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(color=Color.srgb(0.5, 0.5, 1.0), custom_size=BRICK_SIZE),
Transform.from_translation(brick_pos.extend(0.0)),
)
# Scoreboard
commands.spawn(
Scoreboard(),
Text.new("Score: 0"),
TextFont(font_size=40.0),
Node(position_type=PositionType.Absolute, top=Val.px(5.0), left=Val.px(5.0)),
)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 = new_pos_x.clamp(left_bound, right_bound)
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.sections[0].value = f"Score: {score.value}"
def check_for_collisions(
commands: Commands,
score: ResMut[Score],
ball_query: Query[tuple[Mut[Ball], Transform]],
collider_query: Query[tuple[Transform, Has[Brick]], With[Collider]],
):
# Collision detection between the ball and all colliders.
# For each collision, reflect the ball velocity and destroy bricks.
for ball, ball_transform in ball_query:
ball_pos = ball_transform.translation
ball_size = ball_transform.scale.truncate()
for collider_transform, is_brick 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:
if is_brick:
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
# ... implementation ...
return None # PlaceholderRunning 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()