A simple 2D pinball game made with Rust, Bevy and Rapier

gunstein

Gunstein Vatnar

Posted on October 11, 2021

A simple 2D pinball game made with Rust, Bevy and Rapier

Introduction

I hope this simple pinball game can be an introduction for beginners to both the Bevy game engine and the Rapier physics engine.
Here I share a few details about how I implemented Pinball2D.

NB! Some knowledge of the Rust programming language is necessary.

Short video of Pinball2D in action:

Source code

Running the game on the web using WebAssembly

https://pinball2d.vatnar.no/
How to build this WebAssembly will be described in part 2.

Running the game

Rust and Cargo is a prerequisite.

git clone https://github.com/gunstein/Pinball2D.git
cargo run --release
Enter fullscreen mode Exit fullscreen mode

Launch ball with space and move flippers with left and right arrow.

Main links for information about Bevy and Rapier

Disclaimer

This is neither a Bevy nor a Rapier tutorial, but a description of a simple demo game that I missed when I started out with Bevy and Rapier.
There's lots of room for improvements, but I still think it works as an introduction.

Organization

I recommend to use plugins and keep each plugin in a separate file. In my opinion this is tidy and intuitive.
Image of file/plugin structure

Pinball2D has a startup system in every plugin.

impl Plugin for WallsPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app
            .add_startup_system(spawn_walls);
    }
}
Enter fullscreen mode Exit fullscreen mode

Setting up the board

I chose 640px/360px for the size of the game area. This makes up the Bevy coordinate system of the game. Origo in the middle (0,0), x-axis[-180, 180] and y-axis[-320, 320]. (See figure below.)
In earlier versions of bevy_rapier2d (before version 0.13) it was important to keep all sizes and coordinates in meters. That is not correct anymore. You should now use pixels for all sizes and tell Rapier how many pixels there is in a meter.

.add_plugin(RapierPhysicsPlugin::<NoUserData>::pixels_per_meter(PIXELS_PER_METER))
Enter fullscreen mode Exit fullscreen mode

Since Pinball2d already was using all sizes in meters (and since I'm lacy) I decided to define a global const PIXELS_PER_METER and use this const to compute all sizes and coordinates in pixels from meters.

pub const PIXELS_PER_METER : f32 = 492.3;
Enter fullscreen mode Exit fullscreen mode

Example of usage:

let ball_pos = Vec2::new(crate::PIXELS_PER_METER * 0.3, crate::PIXELS_PER_METER * -0.2);
Enter fullscreen mode Exit fullscreen mode

Board design

Spawning wall elements

I use bevy_prototype_lyon to create shapes
https://github.com/Nilirad/bevy_prototype_lyon

The basic process of creating a wall element:

  • Create a shape. Bevy-coordinates is needed, so I scale from rapier coordinates.
  • Make a geometry with the created shape and add a collider to a rigid body when spawning an entity for the wall element. (Colors and other drawing options are added when you create the geometry).

A wall is a static body. Most of the wall colliders should be solid. The exception is the bottom wall collider which is a sensor. A sensor collider will not influence other objects, but emit events when intersections occur. I despawn the ball when the bottom wall is hit and respawn the ball just above the launcher.

Rapier's documentation on colliders must be read:
https://www.rapier.rs/docs/user_guides/bevy_plugin/colliders

Below, the bottom wall is spawned. The collider is a sensor.

    let shape_top_and_bottom_wall = shapes::Rectangle {
        extents: Vec2::new(crate::PIXELS_PER_METER * 0.73, crate::PIXELS_PER_METER * 0.03),
        origin: shapes::RectangleOrigin::Center
    };

    //Spawn bottom wall
    let bottom_wall_pos = Vec2::new(0.0, crate::PIXELS_PER_METER * -0.64);
    commands
        .spawn((
            ShapeBundle {
                path: GeometryBuilder::build_as(&shape_top_and_bottom_wall),
                ..default()
            },
            Fill::color(Color::TEAL),
        ))
        .insert(RigidBody::Fixed)
        .insert(Collider::cuboid(
            shape_top_and_bottom_wall.extents.x / 2.0,
            shape_top_and_bottom_wall.extents.y / 2.0,
        ))
        .insert(Sensor)
        .insert(Transform::from_xyz(
            bottom_wall_pos.x,
            bottom_wall_pos.y,
            0.0,
        ))
        .insert(BottomWall);

Enter fullscreen mode Exit fullscreen mode

Flippers

The flippers are spawned much the same as walls, but are KinematicPositionBased bodies. I keep different movement systems for the right and the left flipper. For every frame it's checked if the flipper key is pressed. Next rotation angle will be set accordingly and clamped to a sensible value.

#[derive(Component)]
struct LeftFlipper{
    point_of_rotation : Vec3,
    curr_angle : f32,
 }

fn left_flipper_movement(
    keyboard_input: Res<Input<KeyCode>>,
    mut left_flippers: Query<(&mut LeftFlipper, &mut Transform), With<LeftFlipper>>,
) {
    for (mut left_flipper, mut left_flipper_transform) in left_flippers.iter_mut() {
        let mut new_angle = left_flipper.curr_angle;
        let change_angle:f32;

        if keyboard_input.pressed(KeyCode::Left)
        {
            change_angle = 0.09;
        }
        else
        {
            change_angle = -0.07;
        }

        new_angle += change_angle;
        let new_clamped_angle = new_angle.clamp(-0.3, 0.3);
        let pivot_rotation = Quat::from_rotation_z(new_clamped_angle - left_flipper.curr_angle);
        left_flipper_transform.rotate_around(left_flipper.point_of_rotation, pivot_rotation);   
        left_flipper.curr_angle = new_clamped_angle;     
    }
}

Enter fullscreen mode Exit fullscreen mode

Pins

Pins are much like wall objects but change color for a second when hit.
I respawn the hit pin to change color. I would have prefered to change it's color without respawning, but couldn't make it work.
The Pin component struct:

#[derive(Component)]
struct Pin{
    timestamp_last_hit : f64,
    position : Vec2,
}
Enter fullscreen mode Exit fullscreen mode

The code below shows how contact events are handled. If the event received is of type "Started" and one of the participants in the event is a pin, then the pin will respawn.

fn handle_pin_events(
    query: Query<(Entity, &Pin), With<Pin>>,
    time: Res<Time>,
    mut contact_events: EventReader<CollisionEvent>,
    mut commands: Commands
) {
    for contact_event in contact_events.iter() {
        for (entity, pin) in query.iter() {
            if let CollisionEvent::Started(h1, h2, _event_flag) = contact_event {
                if h1 == &entity || h2 == &entity {
                    //Respawn to change color
                    let pos = pin.position;
                    let timestamp_last_hit = time.seconds_since_startup();
                    commands.entity(entity).despawn();
                    spawn_single_pin(&mut commands, pos, Some(timestamp_last_hit));
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Ball

The ball is the only entity with a dynamic rigid body type.
Spawning is still similar to other entities:

fn spawn_ball(mut commands: Commands) {
    let ball_pos = Vec2::new(
        crate::PIXELS_PER_METER * 0.3,
        crate::PIXELS_PER_METER * -0.2,
    );

    let shape_ball = shapes::Circle {
        radius: crate::PIXELS_PER_METER * 0.03,
        center: Vec2::ZERO,
    };

    commands
        .spawn((
            ShapeBundle {
                path: GeometryBuilder::build_as(&shape_ball),
                ..default()
            },
            Fill::color(Color::BLACK),
            Stroke::new(Color::TEAL, 2.0),
        ))
        .insert(RigidBody::Dynamic)
        .insert(Sleeping::disabled())
        .insert(Ccd::enabled())
        .insert(Collider::ball(shape_ball.radius))
        .insert(Transform::from_xyz(ball_pos.x, ball_pos.y, 0.0))
        .insert(ActiveEvents::COLLISION_EVENTS)
        .insert(Restitution::coefficient(0.7))
        .insert(Ball);
}
Enter fullscreen mode Exit fullscreen mode

Two things to mention from the code above:

  1. Setting the ActiveEvents::COLLISION_EVENTS bit to 1 enables the collision events involving the collider.
  2. Restitution is a measure for how bouncy the ball is.

The ball is respawned above the launcher when it hits the bottom wall.

fn handle_ball_intersections_with_bottom_wall(
    rapier_context: Res<RapierContext>,
    query_ball: Query<Entity, With<Ball>>,
    query_bottom_wall: Query<Entity, With<BottomWall>>,
    mut commands: Commands
) {
    let mut should_spawn_ball = false;

    for entity_bottom_wall in query_bottom_wall.iter() {
        for entity_ball in query_ball.iter() {
            /* Find the intersection pair, if it exists, between two colliders. */
            if rapier_context.intersection_pair(entity_bottom_wall, entity_ball) == Some(true) {
                commands.entity(entity_ball).despawn();
                should_spawn_ball = true;
            }
        }
    }

    if should_spawn_ball
    {
        spawn_ball(commands);
    }
}
Enter fullscreen mode Exit fullscreen mode

Launcher

The launcher works very much like a flipper, but there is obviously no rotation involved.

fn launcher_movement(
    keyboard_input: Res<Input<KeyCode>>,
    mut launchers: Query<(&mut Launcher, &mut Transform), With<Launcher>>,
) {
    for (launcher, mut launcher_transform) in launchers.iter_mut() {
        let mut next_ypos = launcher_transform.translation.y;

        if keyboard_input.pressed(KeyCode::Space)
        {
            next_ypos = next_ypos + crate::PIXELS_PER_METER * 0.04;
        }
        else
        {
            next_ypos = next_ypos - crate::PIXELS_PER_METER * 0.04;
        }   
        let clamped_ypos = next_ypos.clamp(launcher.start_point.y, launcher.start_point.y + crate::PIXELS_PER_METER * 0.05);
        launcher_transform.translation.y = clamped_ypos;    
    }
} 
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
gunstein
Gunstein Vatnar

Posted on October 11, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related