Exploring WebSocket with Rust and Tide
Around a month ago the tide-websockets were released
[twitter https://twitter.com/_httprs/status/1335006939646840835]
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
.
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.
// 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
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.
// 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
Awesome!! we just connected using websockets to our server, we can even send a message
from the browser console and check the logs
[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.
<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.
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}`;
}
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.
// 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...
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 :-)
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 struct
s to handle the game
//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
.
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
serde = { version = "1.0.118", features = ["derive"] }
Now we need the struct
that will represent the board
#[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 <Id,Board> and have the logic to interact with an individual board.
#[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.
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
( ... )
.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.
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.
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....
Awesome! I just connected to the board and get the INIT
command from the server, let's try make a play...
Woow, that just works and we receive the board state :-) , let's open page to continue play
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.
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!