⚠️ 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 = value

Setup

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 # Placeholder

Running 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()