Rust & Wasm: Build Your Own Breakout Game III
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: