Rust & Wasm: Build Your Own Breakout Game III

Nikhil Gupta
6 min readDec 27, 2022

--

In this article, we will continue on our journey to build the popular breakout game from scratch using Rust & Wasm in a React application using the Bevy game engine.

We will build upon the previous articles available here:

Part I

Part II

Add bricks

When we left off last time, we didn’t have any bricks for a user to actually hit and score. Let’s do it:

Declare Brick component and some constants

#[derive(Component)]
struct Brick;

const BRICK_COLOR: Color = Color::rgb(0.5, 0.5, 1.0);

const BRICK_SIZE: Vec2 = Vec2::new(100., 30.);

const GAP_BETWEEN_PADDLE_AND_BRICKS: f32 = 270.0;
const GAP_BETWEEN_BRICKS: f32 = 5.0;

const GAP_BETWEEN_BRICKS_AND_CEILING: f32 = 20.0;
const GAP_BETWEEN_BRICKS_AND_SIDES: f32 = 20.0;

const TOP_WALL: f32 = 300.;

Compute width and height of the bricks

fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
asset_server: Res<AssetServer>,
) {
// ...
let total_width_of_bricks = (RIGHT_WALL - LEFT_WALL) - 2. * GAP_BETWEEN_BRICKS_AND_SIDES;
let bottom_edge_of_bricks = paddle_y + GAP_BETWEEN_PADDLE_AND_BRICKS;
let total_height_of_bricks = TOP_WALL - bottom_edge_of_bricks - GAP_BETWEEN_BRICKS_AND_CEILING;
}

The function simply uses the constants to define the area within the game where bricks will be added.

Compute number of rows and columns

fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
asset_server: Res<AssetServer>,
) {
// ...
let n_columns = (total_width_of_bricks / (BRICK_SIZE.x + GAP_BETWEEN_BRICKS)).floor() as usize;
let n_rows = (total_height_of_bricks / (BRICK_SIZE.y + GAP_BETWEEN_BRICKS)).floor() as usize;
}

Compute the starting point

fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
asset_server: Res<AssetServer>,
) {
// ...
let center_of_bricks = (LEFT_WALL + RIGHT_WALL) / 2.0;
let n_vertical_gaps = n_columns - 1;
let left_edge_of_bricks = center_of_bricks
- (n_columns as f32 / 2.0 * BRICK_SIZE.x)
- n_vertical_gaps as f32 / 2.0 * GAP_BETWEEN_BRICKS;
}

Render the bricks

fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
asset_server: Res<AssetServer>,
) {
// ...
let offset_x = left_edge_of_bricks + BRICK_SIZE.x / 2.;
let offset_y = bottom_edge_of_bricks + BRICK_SIZE.y / 2.;

for row in 0..n_rows {
for column in 0..n_columns {
let brick_position = Vec2::new(
offset_x + column as f32 * (BRICK_SIZE.x + GAP_BETWEEN_BRICKS),
offset_y + row as f32 * (BRICK_SIZE.y + GAP_BETWEEN_BRICKS),
);

commands.spawn((
SpriteBundle {
sprite: Sprite {
color: BRICK_COLOR,
..default()
},
transform: Transform {
translation: brick_position.extend(0.0),
scale: Vec3::new(BRICK_SIZE.x, BRICK_SIZE.y, 1.0),
..default()
},
..default()
},
Brick,
Collider,
));
}
}
}

Note that the function simply adds the bundle at each row and column as computed above with the distance as GAP_BETWEEN_BRICKS .

Now, if you run the updated app and click on Run Bevy App, the bricks will be rendered on-screen and the ball will simply bounce up-and-down on colliding with the paddle and the bricks on the lower edge.

Hide the brick on collision

As you would expect from a breakout game, the collision should actually hide the brick involved. Let’s modify our check_for_collisions accordingly.

fn check_for_collisions(
+ mut commands: Commands,
mut ball_query: Query<(&mut Velocity, &Transform), With<Ball>>,
- collider_query: Query<(&Transform), With<Collider>>,
+ collider_query: Query<(Entity, &Transform, Option<&Brick>), With<Collider>>,
) {
let (mut ball_velocity, ball_transform) = ball_query.single_mut();
let ball_size = ball_transform.scale.truncate();

- for (transform) in &collider_query {
+ for (collider_entity, transform, maybe_brick) in &collider_query {
if let Some(collision) = collide(
ball_transform.translation,
ball_size,
transform.translation,
transform.scale.truncate(),
) {
+ if maybe_brick.is_some() {
+ commands.entity(collider_entity).despawn();
+ }
+
let mut reflect_x = false;
let mut reflect_y = false;

// ...
}
}
}

Basically, we modified our collider_query to retrieve the entity and an optional to confirm if the entity is actually a brick. If that's the case, we simply despawn the entity.

Now, if you run the updated app and click on Run Bevy App, the bricks vanish as soon as they are hit by the ball. Let's also quickly change the initial ball direction to improve the game mechanics.

-const INITIAL_BALL_DIRECTION: Vec2 = Vec2::new(0.0, -0.5);
+const INITIAL_BALL_DIRECTION: Vec2 = Vec2::new(0.1, -0.5);

Time to add a scorecard!

Since we are able to hit the bricks now, let’s add a scorecard to keep track of the player’s points.

Define the resource and constants

#[derive(Resource)]
struct Scoreboard {
score: usize,
}

const SCOREBOARD_FONT_SIZE: f32 = 40.0;
const SCOREBOARD_TEXT_PADDING: Val = Val::Px(5.0);

const TEXT_COLOR: Color = Color::rgb(0.5, 0.5, 1.0);
const SCORE_COLOR: Color = Color::rgb(1.0, 0.5, 0.5);

Render the scoreboard

fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
asset_server: Res<AssetServer>,
) {
//...

commands.spawn(
TextBundle::from_sections([
TextSection::new(
"Score: ",
TextStyle {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font_size: SCOREBOARD_FONT_SIZE,
color: TEXT_COLOR,
},
),
TextSection::from_style(TextStyle {
font: asset_server.load("fonts/FiraMono-Medium.ttf"),
font_size: SCOREBOARD_FONT_SIZE,
color: SCORE_COLOR,
}),
])
.with_style(Style {
position_type: PositionType::Absolute,
position: UiRect {
top: SCOREBOARD_TEXT_PADDING,
left: SCOREBOARD_TEXT_PADDING,
..default()
},
..default()
}),
);
}

Please make sure that fonts/FiraSans-Bold.ttf and fonts/FiraMono-Medium.ttf are present in the public/assets folder.

Update the score

fn check_for_collisions(
mut commands: Commands,
+ mut scoreboard: ResMut<Scoreboard>,
mut ball_query: Query<(&mut Velocity, &Transform), With<Ball>>,
collider_query: Query<(Entity, &Transform, Option<&Brick>), With<Collider>>,
) {
// ...

if maybe_brick.is_some() {
+ scoreboard.score += 1;
commands.entity(collider_entity).despawn();
}

//...
}

We simply add the scoreboard as an argument and update the score whenever a brick is hit.

Add the resource to Bevy

#[wasm_bindgen]
pub fn run_bevy_app() {
+ .insert_resource(Scoreboard { score: 0 })
.add_startup_system(setup)
.add_system(check_for_collisions)
}

Render the update on-screen

fn update_scoreboard(scoreboard: Res<Scoreboard>, mut query: Query<&mut Text>) {
let mut text = query.single_mut();
text.sections[1].value = scoreboard.score.to_string();
}

Here, we define the function to update the text and then, add it to the startup system like so:

#[wasm_bindgen]
pub fn run_bevy_app() {
+ .add_system(update_scoreboard)
.run();
}

Now, if you run the updated app and click on Run Bevy App, you should see the scoreboard being incremented on each hit :)

The only issue that remains now is that the ball can still pass through the walls and drift away forever! Let’s add the wall bundles to fix this:

Add Wall Bundles

Define an enum

Since we need to add bundles for each of the 4 walls, it will be easier to define an enum and add functions to compute the position and size of the bundle based on its type.

enum WallLocation {
Left,
Right,
Bottom,
Top,
}

impl WallLocation {
fn position(&self) -> Vec2 {
match self {
WallLocation::Left => Vec2::new(LEFT_WALL, 0.),
WallLocation::Right => Vec2::new(RIGHT_WALL, 0.),
WallLocation::Bottom => Vec2::new(0., BOTTOM_WALL),
WallLocation::Top => Vec2::new(0., TOP_WALL),
}
}

fn size(&self) -> Vec2 {
let arena_height = TOP_WALL - BOTTOM_WALL;
let arena_width = RIGHT_WALL - LEFT_WALL;

match self {
WallLocation::Left | WallLocation::Right => {
Vec2::new(WALL_THICKNESS, arena_height + WALL_THICKNESS)
}
WallLocation::Bottom | WallLocation::Top => {
Vec2::new(arena_width + WALL_THICKNESS, WALL_THICKNESS)
}
}
}
}

Define a custom bundle

#[derive(Bundle)]
struct WallBundle {
sprite_bundle: SpriteBundle,
collider: Collider,
}

const WALL_COLOR: Color = Color::rgb(0.8, 0.8, 0.8);

impl WallBundle {
fn new(location: WallLocation) -> WallBundle {
WallBundle {
sprite_bundle: SpriteBundle {
transform: Transform {
translation: location.position().extend(0.0),
scale: location.size().extend(1.0),
..default()
},
sprite: Sprite {
color: WALL_COLOR,
..default()
},
..default()
},
collider: Collider,
}
}
}

Render Wall Bundles

fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
asset_server: Res<AssetServer>,
) {
// ...

commands.spawn(WallBundle::new(WallLocation::Left));
commands.spawn(WallBundle::new(WallLocation::Right));
commands.spawn(WallBundle::new(WallLocation::Bottom));
commands.spawn(WallBundle::new(WallLocation::Top));
}

Now, if you run the updated app and click on Run Bevy App, the ball will reflect off the walls and stay on-screen.

With this, we now have our very own breakout game ready for us to play! Check it out and let me know if you find it interesting :)

If you liked this article, subscribe here to get the complete code and updates for the entire collection:

--

--

No responses yet