andy@andyleclair.dev$>_

First Game!

My first steps with Bevy ECS

Related Listening

Posted on 2025-08-11

Been a minute since I’ve had a new post here, and there’s a good reason for that. On April 29th 2025, my wife and I welcomed our second child to the world! We are so glad to have you here, Jamie Valentine!

Shocking absolutely no-one, having two children has put a bit of a damper on my extracurricular programming. That said, I did find a little time here and there to put together my first “game.”

Game in quotes there, because it’s a zero-player game. I’ve built a clone of Pong Wars using Bevy ECS. Check the code out here

Overall, it was a fun project! I enjoy writing Rust well enough, and working from the Bevy examples, I was able to put this working game together in pretty short order.

I did find my Elixir-y propensity to make a bunch of little helper functions is not in the Rust-way, and that’s fine. I was trying to refactor a few bits to not repeat myself, but it’s actually fine I guess? Judging by the recent Linus Rant™ maybe DRY-ing everything isn’t actually a benefit.

Specifically, I was trying to DRY up this code here:


fn check_for_light_collisions(
    mut commands: Commands,
    mut light_score: ResMut<LightScore>,
    mut dark_score: ResMut<DarkScore>,
    ball_query: Single<(&mut Velocity, &Transform), (With<Ball>, With<Light>, Without<Dark>)>,
    block_query: Query<(Entity, &Transform), (With<Block>, With<Dark>)>,
) {
    let (mut ball_velocity, ball_transform) = ball_query.into_inner();
    for (block_entity, block_transform) in block_query {
        if let Some(collision) = ball_collision(
            BoundingCircle::new(ball_transform.translation.truncate(), BALL_SIZE / 2.),
            Aabb2d::new(
                block_transform.translation.truncate(),
                block_transform.scale.truncate() / 2.,
            ),
        ) {
            // Despawn the dark block and spawn a light block in its' place
            commands.entity(block_entity).despawn();
            commands.spawn((
                Sprite {
                    color: LIGHT_COLOR,
                    ..default()
                },
                block_transform.clone(),
                Block,
                Light,
            ));

            // Update the score
            **light_score += 1;
            **dark_score -= 1;

            // Reflect the ball's velocity when it collides
            let mut reflect_x = false;
            let mut reflect_y = false;

            // Reflect only if the velocity is in the opposite direction of the collision
            // This prevents the ball from getting stuck inside the bar
            match collision {
                Collision::Left => reflect_x = ball_velocity.x > 0.0,
                Collision::Right => reflect_x = ball_velocity.x < 0.0,
                Collision::Top => reflect_y = ball_velocity.y < 0.0,
                Collision::Bottom => reflect_y = ball_velocity.y > 0.0,
            }

            // Reflect velocity on the x-axis if we hit something on the x-axis
            if reflect_x {
                ball_velocity.x = -ball_velocity.x;
            }

            // Reflect velocity on the y-axis if we hit something on the y-axis
            if reflect_y {
                ball_velocity.y = -ball_velocity.y;
            }
        }
    }
}

fn check_for_dark_collisions(
    mut commands: Commands,
    mut light_score: ResMut<LightScore>,
    mut dark_score: ResMut<DarkScore>,
    ball_query: Single<(&mut Velocity, &Transform), (With<Ball>, With<Dark>, Without<Light>)>,
    block_query: Query<(Entity, &Transform), (With<Block>, With<Light>)>,
) {
    let (mut ball_velocity, ball_transform) = ball_query.into_inner();
    for (block_entity, block_transform) in block_query {
        if let Some(collision) = ball_collision(
            BoundingCircle::new(ball_transform.translation.truncate(), BALL_SIZE / 2.),
            Aabb2d::new(
                block_transform.translation.truncate(),
                block_transform.scale.truncate() / 2.,
            ),
        ) {
            // Despawn the dark block and spawn a light block in its' place
            commands.entity(block_entity).despawn();
            commands.spawn((
                Sprite {
                    color: DARK_COLOR,
                    ..default()
                },
                block_transform.clone(),
                Block,
                Dark,
            ));

            // Update the score
            **dark_score += 1;
            **light_score -= 1;

            // Reflect the ball's velocity when it collides
            let mut reflect_x = false;
            let mut reflect_y = false;

            // Reflect only if the velocity is in the opposite direction of the collision
            // This prevents the ball from getting stuck inside the bar
            match collision {
                Collision::Left => reflect_x = ball_velocity.x > 0.0,
                Collision::Right => reflect_x = ball_velocity.x < 0.0,
                Collision::Top => reflect_y = ball_velocity.y < 0.0,
                Collision::Bottom => reflect_y = ball_velocity.y > 0.0,
            }

            // Reflect velocity on the x-axis if we hit something on the x-axis
            if reflect_x {
                ball_velocity.x = -ball_velocity.x;
            }

            // Reflect velocity on the y-axis if we hit something on the y-axis
            if reflect_y {
                ball_velocity.y = -ball_velocity.y;
            }
        }
    }
}

Really, the only difference here is which colors we’re “selecting” in the Query and the opposite-color spawning. I ran into a bunch of issues trying to make a helper, though. My shittiness at Rust was showing for sure!

That all said, this was a fun project. The Bevy template was brand new when I was starting this, so I went with the “rawdogging it all in main.rs” strategy that many of the examples show.

Next I may rebuild it using the template to see how the experience differs. I was interested in getting into some Zig, but this project was fun and maybe I just keep writing Rust!