Around a month ago the tide-websockets were released

Experimental websockets handler for tide based on async-tungstenite

This was awesome!! and something that I was waiting to explore build real time apps with tide.

The first example app I saw using this crate was littoral a chat application made by @jbr and that inspired me to write a small example app too.

In the past I used socket.io and node.js ( check micro-trends ) to build this kind of apps so I think should be a good starting point to create a simple tic-tac-toc game and write about my first iteration with tide and websockets.


Let’s start by creating a new project and add the deps and enable attributes feature for async-std.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
cargo init tic-tac-tide
     Created binary (application) package

cd tic-tide-tide

cargo add tide tide-websockets env_logger async-std futures_util

    Updating 'https://github.com/rust-lang/crates.io-index' index
      Adding tide v0.15.0 to dependencies
      Adding tide-websockets v0.1.0 to dependencies
      Adding env_logger v0.8.2 to dependencies
      Adding async-std v1.8.0 to dependencies
      Adding futures-util v0.3.8 to dependencies

Now we can go to our main.rs and start with the basic, we will have two pages. The index page where you can create a new board to play and the :board where the game lives.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// main.rs

use tide::{Body, Request};
use tide_websockets::{Message as WSMessage, WebSocket, WebSocketConnection};
use futures_util::StreamExt;

#[async_std::main]
async fn main() -> Result<(), std::io::Error> {
    env_logger::init();

    let mut app = tide::new();

    // serve public dir for assets
    app.at("/public").serve_dir("./public/")?;

    // index route
    app.at("/").get(|_| async { Ok(Body::from_file("./public/index.html").await?) });

    // board route
    app.at("/:id")
        .with(WebSocket::new(
            |_req: Request<_>, mut wsc: WebSocketConnection| async move {
                while let Some(Ok(WSMessage::Text(message))) = wsc.next().await {
                    println!("{:?}", message);
                }

                Ok(())
            },
        ))
    .get(|_| async { Ok(Body::from_file("./public/board.html").await?) });

    let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string());
    let addr = format!("0.0.0.0:{}", port);
    app.listen(addr).await?;

    Ok(())
}

Nice! As you notice we will serving assets and html files from public directory, so let’s go ahead and create those at the same level of our src directory

1
2
mkdir -p public/{img,css,js}
touch public/{index.html,board.html}

At this point, for testing let focus on the board. Add basic html and some inline javascript to test the websocket connection.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// board.html
<!DOCTYPE html>
<html lang="en">

<head>
  <title>Tic Tac Tide - Board </title>
  <meta charset="utf-8">
    <title>Tic Tac Tide - a WebSocket example with Tide</title>
  <meta name="author" content="Javier Viola">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="apple-mobile-web-app-capable" content="yes" />

</head>
<body>
<script>
let io;
document.addEventListener("DOMContentLoaded", function() {
    // connect to ws
    const ws_url = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${window.location.pathname}`;
    io = new WebSocket( ws_url );
} );
</script>
</body>
</html>

Run our server with cargo run and go to the board

image

Awesome!! we just connected using websockets to our server, we can even send a message from the browser console and check the logs

image

1
2
3
4
[Running 'cargo run']
    Finished dev [unoptimized + debuginfo] target(s) in 1.30s
     Running `target/debug/tic-tac-tide`
"tide-websockets rocks!!"

So, now that we can connect let’s continue with the index and how to create the boards.

For the homepage and in general we will we using skeleton as a minimal style framework so go ahead and add those files to /public/css and refer to them in the index and board pages.

1
2
3
4
  <link href='//fonts.googleapis.com/css?family=Raleway:400,300,600' rel='stylesheet' type='text/css'>
  <link rel="stylesheet" href="/public/css/normalize.css">
  <link rel="stylesheet" href="/public/css/skeleton.css">
  <link rel="stylesheet" href="/public/css/custom.css">

Full disclosure, the idea of this project is to work with tide-websockets, so for the actual game logic I just adapt the one from this tic-tac-toc js tutorial to work with websockets.

Let’s fast forward add the base html for both pages, you can check the full code for both [here]() , but as a brief the home page will have a new game button that will make a request to the /new endpoint that will return a random board name to connect.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    async function handleStartGame() {
        const response = await fetch( '/new', {
            method: "POST",
            cache: 'no-cache',
            headers: {
                'Content-Type': 'application/json'
            },
            body : JSON.stringify({})
        });

        if( ! response.ok ) throw new Error(`Error generating board`);
        const { board_name } = await response.json();

        window.location.href = `/${board_name}`;
    }

image

Nice! we already have our home page. Let’s back to rust and create the /new endpoint. This will be a simple endpoint that return a random boardId created by concatenating two pets names.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// main.rs
use serde_json::json;
use petname::Petnames;

(...)

    // new route
    app.at("/new").post(|_| async {
        let petnames = Petnames::default();
        let board_name = petnames.generate_one(2, "-");
        Ok( json!({ "board_name" :board_name}))
    });

We will need to add a couple of deps to make it works…

1
2
3
4
cargo add petname serde_json
    Updating 'https://github.com/rust-lang/crates.io-index' index
      Adding petname v1.0.13 to dependencies
     Adding serde_json v1.0.61 to dependencies

Now we can run our code and create a new board :-)

image

Awesome!! Time to go back to rust and write the logic to create the board that will host the game.


We will use a couple of structs to handle the game

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
//main.rs

#[derive(Clone)]
struct Player {
    id: PlayerId, // connection Id
    wsc : WebSocketConnection,
    label : String
}
#[derive(Clone,Serialize,Deserialize,PartialEq,Eq)]
struct PlayerId {
    id: Option<String>,
}

impl Default for PlayerId {
    fn default() -> Self {
        Self {
            id: None
        }
    }
}

The Player struct will have three fields, one for the id that will be created when the user connect ( or used when the user re-connect ) and the others for holding the websocket connection and the label ( ‘X’ or ‘O’ ).

Also, we need to add a couple more of deps for Serialize and Deserialize.

1
2
3
4
cargo add serde serde_derive
    Updating 'https://github.com/rust-lang/crates.io-index' index
      Adding serde v1.0.118 to dependencies
      Adding serde_derive v1.0.118 to dependencies

And enable the derive feature for serde

1
serde = { version = "1.0.118", features = ["derive"] }

Now we need the struct that will represent the board

1
2
3
4
5
6
#[derive(Clone)]
struct Board {
    id: String, // id of the board
    play_book: [String;9],
    players: Vec<Player>
}

Here we will use an array ( play_book ) to hold the game state that will be send over the ws connection to sync the state of the game between the players.

And last we need the State that will be hold a HashMap of and have the logic to interact with an individual board.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#[derive(Clone)]
struct State {
    boards: Arc<RwLock<HashMap<String,Board>>>,
}

impl State {
    fn new() -> Self {
        Self {
            boards: Default::default(),
        }
    }

    async fn add_player_to_board(&self, board_id: &str, mut player: Player ) -> Result<(String,[String;9]),String> {}

    async fn make_play_in_board(&self, board_id: &str, player_label: String,  cell_index: usize) -> tide::Result<()> {}

    async fn send_message(&self, board_id: &str, message: GameCommand) -> tide::Result<()> {}

Beside new we had three functions that allow us to add a player to the board, store the play from a player in a board and last send a ws message to the board to sync the state with the play_book.

Let’s start with the first, add a player to a board.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
    async fn add_player_to_board(&self, board_id: &str, mut player: Player ) -> Result<String,String> {
        let mut boards = self.boards.write().await;
        match boards.entry(board_id.to_owned()) {
            Entry::Vacant(_) => {
                player.label = String::from('X');
                let b = Board {
                    id : board_id.to_owned(),
                    play_book : Default::default(),
                    players: vec![player]
                };
                boards.insert(board_id.to_owned(), b );
                Ok(String::from('X'))
            },
            Entry::Occupied(mut board) => {
                // check if we had the two players
                let mut players = board.get_mut().players.clone();

                // check if already in the board
                let p = players.clone().into_iter().filter(|x| {
                    x.id == player.id
                 }).collect::<Vec<Player>>();

                if p.len() == 1 {
                    let label = p[0].label.clone();
                    board.get_mut().players = players;
                    return Ok(label)
                }

                // check if we can add to the board
                if players.len() < 2 {
                    let other_player = &players[0];
                    player.label = if other_player.label == "X" { String::from("O")} else { String::from("X") };

                    let label = player.label.clone();
                    players.push( player );
                    board.get_mut().players = players;
                    Ok(label)
                } else {
                    return Err(String::from("COMPLETE"))
                }
            }
        }
    }

The general idea here is first check if we already have the board in memory, if not then create and assign the label X to the player. If we already have that board in memory we need to check the number of players to ensure that only two players can be in one board at the same time and we need some extra logic to assign the correct label. Also, we are returning the label here to pass along to the client.

Talking about the client, let see how we call this function from the ws middleware when we receive a new connection

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
( ... )
       .with(WebSocket::new(
            |req: Request<State>, mut wsc: WebSocketConnection| async move {
                let board_id = req.param("id")?;
                let client: PlayerId  = req.query().unwrap_or_default();
                let state = req.state().clone();

                let petnames = Petnames::default();
                let player_id = match client.id {
                    Some( id ) => id,
                    None => petnames.generate_one(2, ".")
                };

                let player = Player {
                    id :  PlayerId {id : Some(player_id.clone())},
                    wsc : wsc.clone(),
                    label: String::from("")
                };

                match state.add_player_to_board(board_id, player).await {
                    Ok( player_label ) => {
                        let boards = state.boards.read().await;
                        wsc.send_json(&json!({
                            "cmd":"INIT",
                            "player":player_label,
                            "play_book" : boards.get(board_id).unwrap().play_book.clone(),
                            "client_id" : player_id
                        })).await?
                    }
                    Err(_) => {
                        wsc.send_json(&json!({
                            "cmd":"COMPLETE"
                        })).await?
                    }
                }
( ... )

So, in any new connection to the board route we try to add the player to the board and send the INIT command with the board status and the label for that player.

We also need to parse the messages that receive from the client, so we listen those using a while loop and parse the message to implement three different actions:

  • PLAY : will send the play from the client, with the cell index.
  • RESET : will reset the board to start over.
  • LEAVE : client leave the board, and we need to notify the other party.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
                while let Some(Ok(WSMessage::Text(message))) = wsc.next().await {
                    println!("{:?}", message);
                    let parts: Vec<&str> = message.split(":").collect();

                    match parts[0] {
                        "PLAY" => {
                            state.make_play_in_board(board_id, parts[1].parse().unwrap(), parts[2].parse().unwrap()).await?;
                            let boards = state.boards.read().await;
                            let play_book = boards.get(board_id).unwrap().play_book.clone();

                            // needs to release the lock here since `send_message` needs to access the board.
                            drop(boards);

                            let cmd = String::from("STATE");
                            state.send_message(board_id, GameCommand { cmd, play_book }).await?;
                        },
                        "RESET" => {
                            state.reset_board(board_id).await?;
                            let cmd = String::from("RESET");
                            state.send_message(board_id, GameCommand{ cmd, play_book : Default::default() }).await?;
                        },
                        "LEAVE" => {
                            state.leave_board(board_id, PlayerId {id : Some(player_id.clone())}).await?;
                            let boards = state.boards.read().await;
                            let play_book = boards.get(board_id).unwrap().play_book.clone();

                            // needs to release the lock here since `send_message` needs to access the board.
                            drop(boards);

                            let cmd = String::from("LEAVE");
                            state.send_message(board_id, GameCommand{ cmd, play_book }).await?;

                        }
                        _ => println!( "INVALID message")
                    }
                }

Let’s for a moment focus in make_play_in_board and follow the happy path to complete a single game.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
    async fn make_play_in_board(&self, board_id: &str, player_label: String,  cell_index: usize) -> tide::Result<()> {
        let mut boards = self.boards.write().await;
        let mut board = boards.get_mut(board_id).unwrap();
        board.play_book[cell_index] = player_label;

        Ok(())
    }

    async fn send_message(&self, board_id: &str, message: GameCommand) -> tide::Result<()> {
        let mut boards = self.boards.write().await;
        match boards.entry(board_id.to_owned()) {
            Entry::Vacant(_) => {
                println!("{} vacant", board_id);
            },
            Entry::Occupied(mut board) => {
                println!("sending state to board {}", board_id);
                for player in &board.get_mut().players {
                    println!("{} message {} - player: {}", board_id, message.cmd, player.label);
                    player.wsc.send_json(&json!({
                        "cmd": message.cmd,
                        "play_book" : message.play_book
                    })).await?
                }
            }
        }

        Ok(())
    }

Great! let’s comment the RESET and LEAVE calls and try our game….

image

Awesome! I just connected to the board and get the INIT command from the server, let’s try make a play…

image

Woow, that just works and we receive the board state :-) , let’s open page to continue play

image

Awesome!!! the game is syncing and working as expected. Now we can complete the rest of functions needed for reset and notify when the other party leave the game.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
    async fn reset_board( &self, board_id: &str) -> tide::Result<()> {
        let mut boards = self.boards.write().await;
        let mut board = boards.get_mut(board_id).unwrap();


        board.play_book = Default::default();

        Ok(())
    }

    async fn leave_board( &self, board_id: &str, player_id: PlayerId) -> tide::Result<()> {
        let mut boards = self.boards.write().await;
        let mut board = boards.get_mut(board_id).unwrap();

        let p = board.players.clone().into_iter().filter(|x| {
            x.id != player_id
         }).collect::<Vec<Player>>();
        board.players = p;

        Ok(())
    }

That’s all for today, we create a basic game using websockets with Tide :). I think there are room to improve and refactor but we we had a nice starting point. You can check the final version in this PR and play the game here.

As always, I write this as a learning journal and there could be another more elegant and correct way to do it and any feedback is welcome.

Thanks!