Multiplayer game on Rust + gRPC with spectator mod. Part 2

Hello everyone. Who missed the first part, it is available here. The source code for the first part is here… The project code at this stage becomes quite large, so in this article I will not give it in full, but will consider only important points. Thanks for the comments on the last article. They have been taken into account and changes along with new crutches have been added to the source code of this part, which is available here

We stopped at the fact that we taught the server and the client to communicate with each other. Let’s teach game objects to move. To do this, we will set one more topic for communication between the client and the server. To file game.proto add the following lines:

   rpc WorldUpdateRequest (ClientActions) returns (WorldStatus);

message ClientActions {
  uint32 playerNumber = 1;
  uint32 clickedButton = 2;
}

message WorldStatus {
  FloatTuple player1Position = 1;
  FloatTuple player2Position = 2;
  Ball ball = 3;
  uint32 playersCount = 4;
  uint32 winner = 5;
}

WorldUpdateRequest – on this topic, we will talk to the server every beat of our game. We will send two numbers from the client – clickedButton – will take on the values ​​0 or 1. Depending on which button the player pressed – moving the racket up or moving the racket down. In general, this parameter can be passed in the type bool … But I would like to implement more logic in the future. For example, a request for a pause from one of the players and its confirmation from the other, the ability to replace oneself with one of the spectators, etc. Therefore, a number was deliberately chosen for this parameter. Also, we transmit playerNumber – the client receives his unique number when asked to play, which we implemented in the first part. This is for the server to understand which client has pressed the button. The server will on its side calculate the new position of the ball, the new position of the players’ rackets and respond with the structure WorldStatus… V WorldStatus everything is familiar from the first part. Only variable is interesting winner… Rather, her type. Since we only have two players, it would be possible to choose the type bool, but if we had implemented the replacement of players with spectators or even some more complex and not obvious at the moment logic, we would have to redo it. Therefore, the unsigned integer type was initially selected.

Please note that sending a request to the server every tick of the game is a bad idea for big games. Because in most game engines I’m familiar with, the default game clock is 1/60 of a second. That is, the function update will be called 60 times per second. For our example, calling the server 60 times per second is okay. For more complex projects, it is better to look towards the streams that are supported by gRPC. This is assuming you are developing something with slow gameplay. For example, a turn-based game. If you want to develop a popular shooter with a lot of simultaneous players, then gRPC is not the best choice.

There is a little trick to generate new methods. Unfortunately, I have not found any other way to generate new methods in an already existing project. Hope if someone knows he will write how to do it. So, we comment on everything that we wrote in cleint.rs and server.rs and paste at the very top of these files:

 fn main() {}

Now we write in the console

 cargo build 

New structures and methods in the file game.rs… We return cleint.rs and server.rs to the original state.

Now let’s get down to implementation.

Server

On the server side, we need to implement the method

 async fn world_update_request(
        &self,
        request: tonic::Request<ClientActions>,
    ) -> Result<tonic::Response<WorldStatus>, tonic::Status>

In addition to implementing this method, we need to describe the physics of our game. Since all calculations will be on the server side.

At the beginning of the method, we pull data from the client request to update the world. Next, we update it:

 let mut world = Arc::clone(&self.world).lock().unwrap().as_ref().unwrap().clone();
        if players_count >= 2 {
            PlayGame::update_world(&mut world, clicked_button, player_number,);
        }
        self.apply_new_world(&world);

This is where all the calculations and updates of the world take place. To implement the physics of the game, we will add several methods to the structure Entity :

 
    fn width(&self) -> f32 {
        self.texture_size.x
    }

    fn height(&self) -> f32 {
        self.texture_size.y
    }

    fn centre(&self) -> Vec2<f32> {
        Vec2::new(
            self.position.x + (self.width() / 2.0),
            self.position.y + (self.height() / 2.0),
        )
    }

    fn bounds(&self) -> Rectangle {
        Rectangle::new(
            self.position.x,
            self.position.y,
            self.width(),
            self.height(),
        )
    }

Structure Rectangle imported from tetra::graphics::Rectangle

Thanks to the method bounds we can understand that the ball hit the racket:

        let player1_bounds = world.player1.bounds();
        let player2_bounds = world.player2.bounds();
        let ball_bounds = world.ball.bounds();

        let paddle_hit = if ball_bounds.intersects(&player1_bounds) {
            Some(&world.player1)
        } else if ball_bounds.intersects(&player2_bounds) {
            Some(&world.player2)
        } else {
            None
        };

Depending on the place of impact on the racket, we calculate the new vector of the ball’s motion:

 if let Some(paddle) = paddle_hit {
            world.ball.velocity.x =
                -(world.ball.velocity.x + (BALL_ACC * world.ball.velocity.x.signum()));

            let offset = (paddle.centre().y - world.ball.centre().y) / paddle.height();

            world.ball.velocity.y += PADDLE_SPIN * -offset;
        }

        if world.ball.position.y <= 0.0
            || world.ball.position.y + world.ball.height() >= world.world_size.y
        {
            world.ball.velocity.y = -world.ball.velocity.y;
        }

In general, all physics is described in guide game engine tetra. In the same place, it is written about a bug that is embedded in this code: if the pitch of the ball becomes larger than the width of the racket, then the ball will fly through the racket.

Then we just collect the structure of which we must respond and send it to the client.

We build the server, check that everything is working and there are no warnings:

% cargo run --bin server
    Finished dev [unoptimized + debuginfo] target(s) in 0.14s
     Running `target/debug/server`
Server listening on [::1]:50051

Customer

On the client side, we need to do two main things: write a new request to the server and implement the update function, which we left blank in the first part:

 fn update(&mut self, ctx: &mut Context) -> tetra::Result {
        Ok(())
 }

To communicate with the server, we will implement the method for GameState:

#[tokio::main]
    async fn world_update_request(&self, clicked_button_number: u32, player_number: u32) -> WorldStatus {
        let request = tonic::Request::new(ClientActions {
            player_number,
            clicked_button: clicked_button_number,
        });
        let mut client = self.client.clone();
        client.world_update_request(request)
            .await.expect("Cannot get World Update from the server").into_inner()
    } 

We will call this function 60 times per second:

fn update(&mut self, ctx: &mut Context) -> tetra::Result {
        let mut clicked_button = 2;
        if input::is_key_down(ctx, Key::Up) {
            clicked_button = 0;
        }
        
        if input::is_key_down(ctx, Key::Down) {
            clicked_button = 1;
        }

        let world_update_request =
            self.world_update_request(clicked_button, self.player_number);
        self.set_updated_values(world_update_request);

        Ok(())
    }

Each bar we check to see if the player pressed the up arrow key or the down arrow key. If so, we send the corresponding signal to the server. In function set_updated_values we just parse the server response and update the values ​​of the game elements.

To display changes in the game, the method draw looks like that:

 fn draw(&mut self, ctx: &mut Context) -> tetra::Result {
        graphics::clear(ctx, Color::rgb(0.392, 0.584, 0.929));
        // 0 - Player 1 won
        // 1 - Player 2 won
        if self.winner == 2 {
            self.player1.texture.draw(ctx, self.player1.position);
            self.ball.texture.draw(ctx, self.ball.position);
            self.player2.texture.draw(ctx, self.player2.position);
        } else {
            let text_offset: Vec2<f32> = Vec2::new(16.0, 16.0);
            let mut message = format!("Winner is: Player ");
            if self.winner == 0 {
                message += "1";
            } else {
                message += "2";
            }
            let mut t: Text = Text::new(message,
                                        Font::vector(ctx, "./resources/DejaVuSansMono.ttf",
                                                     16.0)?,
            );
            t.draw(ctx, text_offset);
        }
        Ok(())
    }

The client is ready. Let’s run and see.

We start the server:

 cargo run --bin server

Launch the client:

cargo run --bin client 

And nothing happens. There is not even a reaction to the pressed keys. This is because the server is waiting for the second player to move the ball.

We launch the second client and play:

We get spectators “for delivery”. Thanks to the chosen approach, we can launch the third, fourth, fifth clients and watch the battle of the first two players without the ability to interfere with them.

This concludes the guide. You can do a bunch of other things. For example, restarting the game after one of the players wins, reacting to a player’s disconnect, we can write a bot that will play if there is no other player. By the way, on the gif above, two bots are fighting among themselves. You can think of a bunch of things, but I leave you room for creativity. Thank you for the attention.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *