DEV Community

Gunstein Vatnar
Gunstein Vatnar

Posted on

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.

Screenshot:
Screenshot of pinball2D

Short video of Pinball2D in action:

Source code

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 and it's important that the startup system in main.rs runs first. This is where the camera and other important parameters is setup. Labels are used to solve this:

impl Plugin for WallsPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app
            .add_startup_system(spawn_walls.system().after("main_setup").label("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.)
You also need a world coordinate system for Rapier. It's not recommended to use the same scale as the Bevy coordinate system. 640px would then correspond to 640 meters and with ordinary gravity things would move really slow. It's better to scale down to a size that corresponds to what you are simulating. In my case I scale 640px to 1.3 meters.
This scale is a config in rapier: rapier_config.scale.

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 {
        width: 0.73*rapier_config.scale,
        height: 0.03*rapier_config.scale,
        origin: shapes::RectangleOrigin::Center
    };

    //Spawn bottom wall
    let bottom_wall_pos : Point2<f32> = Point2::new(0.0, -0.64);
    commands
        .spawn()
        .insert_bundle(
            GeometryBuilder::build_as(
                &shape_top_and_bottom_wall,
                ShapeColors::outlined(Color::TEAL, Color::TEAL),
                DrawMode::Outlined {
                    fill_options: FillOptions::default(),
                    outline_options: StrokeOptions::default(),
                },
                Transform::default(),
            )
        )
        .insert_bundle(RigidBodyBundle {
            body_type: RigidBodyType::Static,
            ..Default::default()
        })
        .insert_bundle(ColliderBundle {
            collider_type: ColliderType::Sensor,
            shape: ColliderShape::cuboid(shape_top_and_bottom_wall.width/rapier_config.scale/2.0, shape_top_and_bottom_wall.height/rapier_config.scale/2.0),
            position: bottom_wall_pos.into(),
            ..Default::default()
        })
        .insert(ColliderPositionSync::Discrete);
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.
I had some hard time figuring out the actual rotation, but got great help on Discord in the Rapier channel:

rbodypos.next_position = Isometry2::rotation_wrt_point(UnitComplex::new(clamped_angle), flipper.point_of_rotation);
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:

struct Pin{
    timestamp_last_hit : f64,
    position : Point2<f32>,
}
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<ContactEvent>,
    mut commands: Commands,
    rapier_config: Res<RapierConfiguration>
) {
    for contact_event in contact_events.iter() {
        for (entity, pin) in query.iter() {
            if let ContactEvent::Started(h1, h2) = contact_event {
                if h1.entity() == entity || h2.entity() == 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, &rapier_config, 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,
    rapier_config: ResMut<RapierConfiguration>,
)
{
    let ball_pos : Point2<f32> = Point2::new(0.3, -0.2);

    let shape_ball = shapes::Circle{
        radius: 0.03 * rapier_config.scale,
        center: Vec2::ZERO,
    };

    commands.spawn()
    .insert_bundle(
        GeometryBuilder::build_as(
            &shape_ball,
            ShapeColors::outlined(Color::TEAL, Color::BLACK),
            DrawMode::Stroke(StrokeOptions::default().with_line_width(2.0)),
            Transform::default(),
        )
    )
    .insert_bundle(RigidBodyBundle::default())
    .insert_bundle(ColliderBundle {
        shape: ColliderShape::ball(shape_ball.radius/rapier_config.scale),
        collider_type: ColliderType::Solid,
        flags: (ActiveEvents::INTERSECTION_EVENTS).into(),
        position: ball_pos.into(),
        material: ColliderMaterial {
            restitution: 0.7,
            ..Default::default()
        },
        ..ColliderBundle::default()
    })
    .insert(ColliderPositionSync::Discrete)
    .insert(Ball);
}
Enter fullscreen mode Exit fullscreen mode

Two things to mention from the code above:

  1. The ActiveEvents::INTERSECTION_EVENTS flag must be present in one of the parties in a colliding relationship in order to receive intersection events. An intersection event is emitted when one of the colliders is a sensor.
  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_events(
    mut intersection_events: EventReader<IntersectionEvent>,
    query: Query<Entity, With<Ball>>,
    mut commands: Commands,
    rapier_config: ResMut<RapierConfiguration>
) {

    let mut should_spawn_ball = false;
    for intersection_event in intersection_events.iter() {
        for entity in query.iter() {
            if intersection_event.collider1.entity() == entity{
                commands.entity(entity).despawn();
                should_spawn_ball = true;
            }
            else if intersection_event.collider2.entity() == entity{
                commands.entity(entity).despawn();
                should_spawn_ball = true;
            }
        }
    }

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

Launcher

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

Discussion (2)

Collapse
chenge profile image
chenge
2021-10-16 16:28:25.919 pinball2d[3858:318021] -[MTLIGAccelDevice readWriteTextureSupport]: unrecognized selector sent to instance 0x7ff47180d800
fatal runtime error: Rust cannot catch foreign exceptions
Abort trap: 6
Enter fullscreen mode Exit fullscreen mode

fail to run! help please!

Collapse
gunstein profile image
Gunstein Vatnar Author • Edited on

Are you able to run any of the graphics examples that are included with Bevy?
Did you run rustup update before building?
What OS are you on (and version)?
I've built and run pinball2d without problem on Ubuntu 20.04, OSX 11.6 and Windows10.

Your error message may indicate OSX and some Metal problems. I´m not sure.
If you have problems running the Bevy examples: Maybe try to ask the same question on Discord and the Bevy channel.