Multiplayer game on Rust + GRPC with spectator mod

Hello everyone. This is a small guide on how to create multiplayer games. I am learning about rust, so some things may not be entirely correct. Hope the rust gurus will correct me if they see anything wrong.

We will be doing multiplayer ping pong. Source code available here

Instruments

  • Rust is a programming language. Great programming language. Even if you are not going to write in it, I recommend that you learn the basic concepts of the language.

  • GRPC – A framework for remote procedure calls. Everything is simple here. Imagine that you want to chat with someone on a pre-voiced topic. Here is the same thing – in the proto – format, the previously agreed topics for communication between the client and the server are described.

  • Tetra is a game engine. Very simple. We don’t need anything complicated for the first project.

Project setup and GRPC

Let’s start by creating a project:

cargo new ping_pong_multiplayer

In folder src create two files: client.rs and server.rs – one for the client, the other for the server.

At the root of the project, create build.rs – to generate GRPC code.

main.rs delete.

File Cargo.toml will look like this:

[package] 
name = "ping_pong_multiplayer" 
version = "0.1.0" 
edition = "2018" 

[dependencies] 
prost = "^0.8.0" 
tonic = "^0.5.2" 
tetra = "^0.6.5" 
tokio = { version = "^1.12.0", features = ["macros", "rt-multi-thread"] }
rand = "0.8.4" 

[build-dependencies] 
tonic-build = "^0.5.2" 

#server binary 
[[bin]] 
name = "server" 
path = "src/server.rs" 

#client binary 
[[bin]] 
name = "client" 
path = "src/client.rs" 

The dependencies prost and tonik are for GRPC, tokio is for the server, rand is for randomness in the game, and tetra is the game engine. The build-dependencies mentions tonic-build – it is needed for code generation from the proto file.

Further, in the folder src create a new directory proto, inside it the file game.proto… Here we will describe what the clients will talk about with the server. In general, GRPC has many communication options and streaming and bidirectional streaming. I will not dwell on each one. We will take the simplest option: the client sends a request, the server returns a response.

Opening the file game.proto and print:

syntax = "proto3"; 

package game; 

service GameProto {   
	rpc PlayRequest (PlayGameRequest) returns (PlayGameResponse); 
} 

message PlayGameRequest {   
	FloatTuple windowSize = 1;   
  FloatTuple player1Texture = 2;   
  FloatTuple player2Texture = 3;   
  FloatTuple ballTexture = 4; 
} 
message PlayGameResponse {   
	FloatTuple player1Position = 1;   
  FloatTuple player2Position = 2;
	uint32 playersCount = 3;   
  uint32 currentPlayerNumber = 4;   
  Ball ball = 5; 
} 
message Ball {   
	FloatTuple position = 1;   
  FloatTuple velocity = 2; 
} 
message FloatTuple {   
	float x = 1;   
  float y = 2; 
}

In the first line, we indicate the version of the syntax. Next comes the packet initiation. In line

rpc PlayRequest (PlayGameRequest) returns (PlayGameResponse);

describe what the client will talk about with the server. Here we will send a request by name PlayRequest with type PlayGameRequest to the server and receive the data type in response PlayGameResponse… What lies in this data is described below:

message PlayGameRequest {
  FloatTuple windowSize = 1;
  FloatTuple player1Texture = 2;
  FloatTuple player2Texture = 3;
  FloatTuple ballTexture = 4;
}

When a client requests permission to play from the server, we send the size of the window, the size of the textures of the players (in our case, the rackets) and the size of the ball. The sizes of game objects could be stored on the server so as not to send them, but in this case we would have two places that need to be updated if we suddenly changed the textures – the server and the client.

In response from the server, we reply:

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

Information where the rackets should be located in the window, the total number of players at the table, the serial number of the current player and the position of the ball.

Data types

message Ball {
  FloatTuple position = 1;
  FloatTuple velocity = 2;
}
message FloatTuple {
  float x = 1;
  float y = 2;
}

auxiliary.

All of them will turn into structures after code generation.

I will not be packing data in this guide. For example,

  uint32 playersCount = 3;
  uint32 currentPlayerNumber = 4; 

It could be packed into one uint32, because I doubt that we will now make such a popular game that the number of players would exceed uint16, which is 65535 decimal. But the topic of data packaging is beyond the scope of this guide.

Now we delete main.rsand in client.rs and server.rs we prescribe:

 fn main(){}

build.rs will look like this:

 fn main() -> Result<(), Box<dyn std::error::Error>> {
    tonic_build::configure()
        .compile(
            &["src/proto/game.proto"],
            &["src/proto"],
        ).unwrap();
    Ok(())
}

To generate code from proto file, just run the build:

 cargo build

As a result, in the folder targetdebugbuildping_pong_multiplayer-tetra_check-e8cc5eb2d2c25880out there will be a file game.rs… In your case, the hash part of the folder name ping_pong_multiplayer-tetra_check-e8cc5eb2d2c25880 will be different. You can open this file – we will use it when writing both the client and the server. We can regulate where the generated file will be folded. For example, if we create a folder srcgenerated and indicate in build.rs:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    tonic_build::configure()
        .out_dir("src/generated") 
        .compile(
            &["src/proto/game.proto"],
            &["src/proto"], 
        ).unwrap();
    Ok(())
} 

Then the generated file will be in the folder srcgenerated

Server

In order for the server and client to have access to the generated file, create in the folder src file generated_shared.rs with the following content:

tonic::include_proto!("game"); 

We now have everything to start writing the server:

use tonic::transport::Server;
use generated_shared::game_proto_server::{GameProto, GameProtoServer};
use generated_shared::{Ball, FloatTuple, PlayGameRequest, PlayGameResponse};

mod generated_shared;

pub struct PlayGame {
}

impl PlayGame {
    fn new() -> PlayGame {
        PlayGame {
        }
    }
}

#[tonic::async_trait]
impl GameProto for PlayGame {
    async fn play_request(
        &self,
        request: tonic::Request<PlayGameRequest>,
    ) -> Result<tonic::Response<PlayGameResponse>, tonic::Status> {
        unimplemented!()
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr = "[::1]:50051".parse()?;
    let play_game = PlayGame::new();
    println!("Server listening on {}", addr);
    Server::builder()
        .add_service(GameProtoServer::new(play_game))
        .serve(addr)
        .await?;
    Ok(())
} 

This is an empty frame. After launching, you will see several warnings. Ignore them for now:

% cargo run --bin server
   Compiling ping_pong_multiplayer v0.1.0 
warning: unused imports: `Ball`, `FloatTuple`
 --> src/server.rs:3:24
  |
3 | use generated_shared::{Ball, FloatTuple, PlayGameRequest, PlayGameResponse};
  |                        ^^^^  ^^^^^^^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

warning: unused variable: `request`
  --> src/server.rs:20:9
   |
20 |         request: tonic::Request<PlayGameRequest>,
   |         ^^^^^^^ help: if this is intentional, prefix it with an underscore: `_request`
   |
   = note: `#[warn(unused_variables)]` on by default

warning: `ping_pong_multiplayer` (bin "server") generated 2 warnings
    Finished dev [unoptimized + debuginfo] target(s) in 1.70s
     Running `target/debug/server`
Server listening on [::1]:50051 

In this code, this part:

async fn play_request(
        &self,
        request: tonic::Request<PlayGameRequest>,
    ) -> Result<tonic::Response<PlayGameResponse>, tonic::Status> {
        uninmplemented!();
    } 

we took from the generated file. This is where we get to the entrance PlayGameRequest and we will answer the client PlayGameResponse

I will immediately give the finished code and comment on it:

use tonic::{transport::Server, Response};
use generated_shared::game_proto_server::{GameProto, GameProtoServer};
use generated_shared::{Ball, FloatTuple, PlayGameRequest, PlayGameResponse};
use std::sync::{Mutex, Arc};
use tetra::math::Vec2;
use rand::Rng;

mod generated_shared;

const BALL_SPEED: f32 = 5.0;

#[derive(Clone)]
struct Entity {
    texture_size: Vec2<f32>,
    position: Vec2<f32>,
    velocity: Vec2<f32>,
}

impl Entity {
    fn new(texture_size: Vec2<f32>, position: Vec2<f32>) -> Entity {
        Entity::with_velocity(texture_size, position, Vec2::zero())
    }    
    fn with_velocity(texture_size: Vec2<f32>, position: Vec2<f32>, velocity: Vec2<f32>) -> Entity {
        Entity { texture_size, position, velocity }
    }
}

#[derive(Clone)]
struct World {
    player1: Entity,
    player2: Entity,
    ball: Entity,
    world_size: Vec2<f32>,
    winner: u32,
}

pub struct PlayGame {
    world: Arc<Mutex<Option<World>>>,
    players_count: Arc<Mutex<u32>>,
}

impl PlayGame {
    fn new() -> PlayGame {
        PlayGame {
            world: Arc::new(Mutex::new(None)),
            players_count: Arc::new(Mutex::new(0u32)),
        }
    }
    fn init(&self, window_size: FloatTuple, player1_texture: FloatTuple,
            player2_texture: FloatTuple, ball_texture: FloatTuple) {
        let window_width = window_size.x;
        let window_height = window_size.y;
        let world = Arc::clone(&self.world);
        let mut world = world.lock().unwrap();
        let players_count = Arc::clone(&self.players_count);
        let players_count = players_count.lock().unwrap().clone();
        let mut ball_velocity = 0f32;
        if players_count >= 2 {
            let num = rand::thread_rng().gen_range(0..2);
            if num == 0 {
                ball_velocity = -BALL_SPEED;
            } else {
                ball_velocity = BALL_SPEED;
            }
        }
        *world =
            Option::Some(World {
                player1: Entity::new(
                    Vec2::new(player1_texture.x, player1_texture.y),
                    Vec2::new(
                        16.0,
                        (window_height - player1_texture.y) / 2.0,
                    ),
                ),
                player2: Entity::new(
                    Vec2::new(player2_texture.x, player2_texture.y),
                    Vec2::new(
                        window_width - player2_texture.y - 16.0,
                        (window_height - player2_texture.y) / 2.0,
                    ),
                ),
                ball: Entity::with_velocity(
                    Vec2::new(ball_texture.x, ball_texture.y),
                    Vec2::new(
                        window_width / 2.0 - ball_texture.x / 2.0,
                        window_height / 2.0 - ball_texture.y / 2.0,
                    ),
                    Vec2::new(
                        ball_velocity,
                        0f32,
                    ),
                ),
                world_size: Vec2::new(window_size.x, window_size.y),
                // No one win yet
                winner: 2,
            });
    }
    fn increase_players_count(&self) {
        let players_count = Arc::clone(&self.players_count);
        let mut players_count = players_count.lock().unwrap();
        *players_count += 1;
    }
}

#[tonic::async_trait]
impl GameProto for PlayGame {
    async fn play_request(
        &self,
        request: tonic::Request<PlayGameRequest>,
    ) -> Result<tonic::Response<PlayGameResponse>, tonic::Status> {
        let pgr: PlayGameRequest = request.into_inner();
        let window_size = pgr.window_size.unwrap();
        let player1_texture = pgr.player1_texture.unwrap();
        let player2_texture = pgr.player2_texture.unwrap();
        let ball_texture_height = pgr.ball_texture.unwrap();
        self.increase_players_count();
        self.init(window_size, player1_texture,
                  player2_texture, ball_texture_height);
        let world = Arc::clone(&self.world).lock().unwrap().as_ref().unwrap().clone();
        let current_players = Arc::clone(&self.players_count);
        let current_players = current_players.lock().unwrap();
        let reply = PlayGameResponse {
            player1_position: Option::Some(FloatTuple {
                x: world.player1.position.x,
                y: world.player1.position.y,
            }),
            player2_position: Option::Some(FloatTuple {
                x: world.player2.position.x,
                y: world.player2.position.y,
            }),
            current_player_number: current_players.clone(),
            players_count: current_players.clone(),
            ball: Option::Some(Ball {
                position: Option::Some(FloatTuple {
                    x: world.ball.position.x,
                    y: world.ball.position.y,
                }),
                velocity: Option::Some(FloatTuple {
                    x: world.ball.velocity.x,
                    y: world.ball.velocity.y,
                }),
            }),
        };
        Ok(Response::new(reply))
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr = "[::1]:50051".parse()?;
    let play_game = PlayGame::new();
    println!("Server listening on {}", addr);
    Server::builder()
        .add_service(GameProtoServer::new(play_game))
        .serve(addr)
        .await?;
    Ok(())
} 

Our “main” structure is PlayGame… This is where we store the whole world and the current number of players. Both fields are wrapped in Arc<Mutex<>> because access to these structures will be multithreaded. In general, rust is a paradise for programming multithreaded programs. It turns out only slightly verbose.

First things first, we receive data from the client:

let pgr: PlayGameRequest = request.into_inner();  

This structure (PlayGameRequest) we can find in the generated file to see what fields are there. Next, from the input data, we pull out:

let window_size = pgr.window_size.unwrap();
let player1_texture = pgr.player1_texture.unwrap();
let player2_texture = pgr.player2_texture.unwrap(); 
let ball_texture_height = pgr.ball_texture.unwrap();

With each new client, we need to increase the number of players:

fn increase_players_count(&self) {
	let players_count = Arc::clone(&self.players_count);
	let mut players_count = players_count.lock().unwrap();
	*players_count += 1;
}

This is a normal change to data wrapped in Arc<Mutex<>>

With the data from the client, we need to initialize the world. To do this, call the function self.init()… In general, there is nothing remarkable here except

let mut ball_velocity = 0f32;
if players_count >= 2 {
    let num = rand::thread_rng().gen_range(0..2);
    if num == 0 {
        ball_velocity = -BALL_SPEED;
    } else {
        ball_velocity = BALL_SPEED;
    }
} 

If there is only one player at the table and the second is not yet, then the ball is in place – its speed is 0. If the second player comes, then the game begins and the ball should start moving. I would like it to start moving in a random direction. Therefore, either 0 or 1 is generated, and depending on what is dropped, the ball moves to the left or to the right.

After we have initiated the world for the client, we need to return it in response. To do this, we must respond with the structure PlayGameResponse – its fields and “insides” can also be seen in the generated game.rs file. We compile, run. We check that everything works:

% cargo run --bin server
   Compiling ping_pong_multiplayer v0.1.0 (/Users/macbook/rust/IdeaProjects/ping_pong_multiplayer)
    Finished dev [unoptimized + debuginfo] target(s) in 5.62s
     Running `target/debug/server`
Server listening on [::1]:50051 

Please note that all warning messages are gone.

Customer

As I mentioned, we will be using the game engine tetra… It’s very simple and easy to figure out. Actually, ping-pong was chosen because they have a guide on how to create this particular game on their website.

Before writing a client, you need to load resources. Create a folder resources at the root of the project. We load pictures from the repository there.

We can now write the wireframe:

use tetra::graphics::{self, Color, Texture};
use tetra::math::Vec2;
use tetra::{TetraError};
use tetra::{Context, ContextBuilder, State};

mod generated_shared;

const WINDOW_WIDTH: f32 = 1200.0;
const WINDOW_HEIGHT: f32 = 720.0;

fn main() -> Result<(), TetraError> {
    ContextBuilder::new("Pong", WINDOW_WIDTH as i32, WINDOW_HEIGHT as i32)
        .quit_on_escape(true)
        .build()?
        .run(GameState::new)
}

struct Entity {
    texture: Texture,
    position: Vec2<f32>,
    velocity: Vec2<f32>,
}
impl Entity {
    fn new(texture: &Texture, position: Vec2<f32>) -> Entity {
        Entity::with_velocity(&texture, position, Vec2::zero())
    }
    fn with_velocity(texture: &Texture, position: Vec2<f32>, velocity: Vec2<f32>) -> Entity {
        Entity { texture: texture.clone(), position, velocity }
    }
}

struct GameState {
    player1: Entity,
    player2: Entity,
    ball: Entity,
    player_number: u32,
    players_count: u32,
}
impl GameState {
    fn new(ctx: &mut Context) -> tetra::Result<GameState> {
        let player1_texture = Texture::new(ctx, "./resources/player1.png")?;
        let player2_texture = Texture::new(ctx, "./resources/player2.png")?;
        let ball_texture = Texture::new(ctx, "./resources/ball.png")?;
        Ok(GameState {
            player1: Entity::new(&player1_texture, Vec2::new(16., 100.)),
            player2: Entity::new(&player2_texture, Vec2::new(116., 100.)),
            ball: Entity::with_velocity(&ball_texture, Vec2::new(52., 125.), Vec2::new(0., 0.)),
            player_number: 0u32,
            players_count: 0u32,
        })
    }
}
impl State for GameState {
    fn update(&mut self, ctx: &mut Context) -> tetra::Result {
        Ok(())
    }
    fn draw(&mut self, ctx: &mut Context) -> tetra::Result {
        graphics::clear(ctx, Color::rgb(0.392, 0.584, 0.929));
        self.player1.texture.draw(ctx, self.player1.position);
        self.player2.texture.draw(ctx, self.player2.position);
        self.ball.texture.draw(ctx, self.ball.position);
        Ok(())
    }
} 

You can see that on the client side we also have a structure Entity and its only difference from the server structure is the data type for the field texture… In general, if you implement the trait Send for data type Texture, then we could move this structure into a file common for the client and server. But this is slightly outside the scope of this guide.

You can also pay attention to

impl State for GameState 

here we have functions update and draw… Tetra requires the implementation of these functions to display and modify the game.

You can run and see what a window with a blue background is drawn, rackets and a ball are drawn:

To communicate with the server, let’s write a small function:

async fn establish_connection() -> GameProtoClient<tonic::transport::Channel> {
    GameProtoClient::connect("http://[::1]:50051").await.expect("Can't connect to the server")
} 

Yet again, GameProtoClient declared in the generated file. We will use this connection throughout our game. Since this is a future, we must stop program execution to create a connection. Also, we must transfer it further into the context of the game. Therefore the function main now looks like this:

fn main() -> Result<(), TetraError> {
    let rt = tokio::runtime::Runtime::new().expect("Error runtime creation");
    let mut client = rt.block_on(establish_connection());
    ContextBuilder::new("Pong", WINDOW_WIDTH as i32, WINDOW_HEIGHT as i32)
        .quit_on_escape(true)
        .build()?
        .run(|ctx|GameState::new(ctx, &mut client))
} 

This is a typical work with a future. In general, rust has a whole separate crate for working with future, but we won’t need it.

In total, we have a connection, we know what the server expects from us and what it will respond to. It remains only to write this:

use tetra::graphics::{self, Color, Texture};
use tetra::math::Vec2;
use tetra::{TetraError};
use tetra::{Context, ContextBuilder, State};
use generated_shared::game_proto_client::GameProtoClient;
use generated_shared::{FloatTuple, PlayGameRequest, PlayGameResponse};

mod generated_shared;

const WINDOW_WIDTH: f32 = 1200.0;
const WINDOW_HEIGHT: f32 = 720.0;

async fn establish_connection() -> GameProtoClient<tonic::transport::Channel> {
    GameProtoClient::connect("http://[::1]:50051").await.expect("Can't connect to the server")
}

fn main() -> Result<(), TetraError> {
    let rt = tokio::runtime::Runtime::new().expect("Error runtime creation");
    let mut client = rt.block_on(establish_connection());
    ContextBuilder::new("Pong", WINDOW_WIDTH as i32, WINDOW_HEIGHT as i32)
        .quit_on_escape(true)
        .build()?
        .run(|ctx|GameState::new(ctx, &mut client))
}

struct Entity {
    texture: Texture,
    position: Vec2<f32>,
    velocity: Vec2<f32>,
}
impl Entity {
    fn new(texture: &Texture, position: Vec2<f32>) -> Entity {
        Entity::with_velocity(&texture, position, Vec2::zero())
    }
    fn with_velocity(texture: &Texture, position: Vec2<f32>, velocity: Vec2<f32>) -> Entity {
        Entity { texture: texture.clone(), position, velocity }
    }
}

struct GameState {
    player1: Entity,
    player2: Entity,
    ball: Entity,
    player_number: u32,
    players_count: u32,
    client: GameProtoClient<tonic::transport::Channel>,
}
impl GameState {
        fn new(ctx: &mut Context, client : &mut GameProtoClient<tonic::transport::Channel>) -> tetra::Result<GameState> {
            let player1_texture = Texture::new(ctx, "./resources/player1.png")?;
            let ball_texture = Texture::new(ctx, "./resources/ball.png")?;
            let player2_texture = Texture::new(ctx, "./resources/player2.png")?;
            let play_request = GameState::play_request(&player1_texture, &player2_texture, &ball_texture, client);
            let ball = play_request.ball.expect("Cannot get ball's data from server");
            let ball_position = ball.position.expect("Cannot get ball position from server");
            let ball_position = Vec2::new(
                ball_position.x,
                ball_position.y,
            );
            let ball_velocity = ball.velocity.expect("Cannot get ball velocity from server");
            let ball_velocity = Vec2::new(
                ball_velocity.x,
                ball_velocity.y,
            );
            let player1_position = &play_request.player1_position
                .expect("Cannot get player position from server");
            let player1_position = Vec2::new(
                player1_position.x,
                player1_position.y,
            );
            let player2_position = &play_request.player2_position
                .expect("Cannot get player position from server");
            let player2_position = Vec2::new(
                player2_position.x,
                player2_position.y,
            );
            let player_number = play_request.current_player_number;
            Ok(GameState {
                player1: Entity::new(&player1_texture, player1_position),
                player2: Entity::new(&player2_texture, player2_position),
                ball: Entity::with_velocity(&ball_texture, ball_position, ball_velocity),
                player_number,
                players_count: player_number,
                client: client.clone(),
            })
        }
    #[tokio::main]
    async fn play_request(player1_texture: &Texture, player2_texture: &Texture, ball_texture: &Texture,
                          client : &mut GameProtoClient<tonic::transport::Channel>) -> PlayGameResponse {
        let request = tonic::Request::new(PlayGameRequest {
            window_size: Some(FloatTuple { x: WINDOW_WIDTH, y: WINDOW_HEIGHT }),
            player1_texture: Some(
                FloatTuple { x: player1_texture.width() as f32, y: player1_texture.height() as f32 }
            ),
            player2_texture: Some(
                FloatTuple { x: player2_texture.width() as f32, y: player2_texture.height() as f32 }
            ),
            ball_texture: Some(
                FloatTuple { x: ball_texture.width() as f32, y: ball_texture.height() as f32 }
            ),
        });
        client.play_request(request).await.expect("Cannot get Play Response the server").into_inner()
    }
}

impl State for GameState {
    fn update(&mut self, ctx: &mut Context) -> tetra::Result {
        Ok(())
    }
    fn draw(&mut self, ctx: &mut Context) -> tetra::Result {
        graphics::clear(ctx, Color::rgb(0.392, 0.584, 0.929));
        self.player1.texture.draw(ctx, self.player1.position);
        self.player2.texture.draw(ctx, self.player2.position);
        self.ball.texture.draw(ctx, self.ball.position);
        Ok(())
    }
} 

There is nothing new here for us. We created a function to request a game: play_request… In the generated file, there is a function with the same name – there we looked at what it expects at the input and what it returns.

You can start the server:

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

Launch the client. Ignore the warning – we’ll need these fields later:

% cargo run --bin client
warning: unused variable: `ctx`
   --> src/client.rs:104:26
    |
104 |     fn update(&mut self, ctx: &mut Context) -> tetra::Result {
    |                          ^^^ help: if this is intentional, prefix it with an underscore: `_ctx`
    |
    = note: `#[warn(unused_variables)]` on by default

warning: field is never read: `velocity`
  --> src/client.rs:28:5
   |
28 |     velocity: Vec2<f32>,
   |     ^^^^^^^^^^^^^^^^^^^
   |
   = note: `#[warn(dead_code)]` on by default

warning: field is never read: `player_number`
  --> src/client.rs:42:5
   |
42 |     player_number: u32,
   |     ^^^^^^^^^^^^^^^^^^

warning: field is never read: `players_count`
  --> src/client.rs:43:5
   |
43 |     players_count: u32,
   |     ^^^^^^^^^^^^^^^^^^

warning: field is never read: `client`
  --> src/client.rs:44:5
   |
44 |     client: GameProtoClient<tonic::transport::Channel>,
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

warning: `ping_pong_multiplayer` (bin "client") generated 5 warnings
    Finished dev [unoptimized + debuginfo] target(s) in 0.44s
     Running `target/debug/client`

And see that the rackets and the ball are in the correct places on the screen:

That’s all this time. Thank you for the attention. In the next part, we will add object movement, paddle control, and player win information.

Similar Posts

Leave a Reply

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