This post should have been on my personal blog but I haven’t figured out a good domain name for it yet!

Why thaas app?

You might not be able to guess the answer to this question. It’s not fueled by my love for thaas games— I haven’t played Digu or Bondi in over 8 years, and I only learned how to play Dhihaeh earlier this year.

It’s not a money-making venture either. I don’t have any plans to turn thaas.app into a profit-generating machine. The primary reason behind creating this app might sound a bit unusual— I built it to improve my Rust programming skills.

There are a few other reasons in the mix, like diving into the world of websockets (which turned out to be quite fun) and experimenting with React Server Components in Next.js. And yes, playing card games with friends still based in the Maldives was a nice side effect of this whole venture.

What’s next?

In terms of new features and new games, probably not a lot. But I’ll try to fix any bugs that may come up and make smaller improvements as time permits.

I’ve received numerous requests to turn thaas.app into a mobile app, but my interests lie elsewhere. If you’re an app developer excited about this challenge, feel free to ping me! Let’s collaborate to bring thaas.app to iOS and Android.

How it all started

Rust is a relatively new programming language, v1.0 was released in 2015. Rust piqued my interest in 2020, a time when my focus was primarily on Machine Learning and related fields dominated by Python. Python’s robust ecosystem and user-friendly syntax in those domains overshadowed my initial curiosity about Rust.

During this period, the cryptocurrency craze fueled Rust’s popularity. However, the prevalence of Rust jobs, especially in mainstream software companies, was relatively sparse, particularly in Berlin.

In 2022, a former coworker presented Nushell, a Rust-based shell. While my exploration of Nushell was brief due to my entrenched zsh habits, it reignited my interest in Rust. Gradually delving into the language, I started with the Rust book and advanced to “Zero to Prod” by Luca Palmieri, generously provided by Luca himself.

Zero to Prod became pivotal in my Rust learning journey, marked by my first commit on August 13th, 2022. Inspired by the newsletter API service in Zero to Prod, I started working on the development of Thaas App in late 2023.

Technical Overview: Thaas App Architecture

For those interested in technical details, let’s explore further!

Tech Stack:

Next.js (React + React Server Components) for the frontend, hosted by Vercel. Backend runs on a Rust API (axum + tokio) split into API and game logic crates.

Deployment

Frontend: Next.js App on Vercel

The Next.js app is deployed using Vercel platform. The free tier offered by Vercel provides ample resources for our current usage patterns.

Backend Services:

  1. API Hosting with Fly.io:

    • The primary API is hosted on fly.io. Leveraging their free allowances, Thaas API benefits from fast startup times, typically around 400ms, making it responsive and user-friendly. The firecracker micro VMs running the Thaas API exhibit minimal memory usage (baseline of 50MB) and CPU usage (0.5%), aligning with the efficiency explained in Why Rust in Production?.
    • The current deployment utilizes a shared-cpu-1x with 512MB RAM and a concurrency limit of 500 connections. The monthly costs have not exceeded $0.1, thanks to the server’s ability to scale down to zero during idle periods. With Rust’s fast cold starts and Fly’s serverless architecture, Thaas app is well-prepared for potential spikes in user activity.
  2. Data Storage:

    • Redis: Used for storing Game data and employing keyspace notifications for real-time updates.
    • PostgreSQL: Stores user and Game metadata.
  3. LiveKit Audio Chat Service

    • A small DigitalOcean VM is dedicated to self-hosting a LiveKit audio chat service.


In summary, the deployment architecture strikes a balance between performance, cost-effectiveness, and scalability, ensuring a smooth experience for Thaas players.

API

I’m going to focus mainly on the real time card game aspect in this and skip over the usual API stuff like auth, middleware etc. I highly recommend the “Zero to Prod” if you’re interested in that.

Game data

Before delving into deeper the Game crate, let’s assume the existence of structs (Bondi, Digu, or Dhihaeh) containing game data. These structs implement the Game trait, defining functions applicable to the game.

Serialization and deserialization are facilitated through the Serialize and Deserialize traits from the serde crate. Game data resides in Redis storage as key-value pairs, with the game code as the key. Utilizing the rmp_serde crate for serialization and deserialization proves advantageous, offering faster and more space-efficient data handling compared to standard JSON. A comprehensive benchmark comparison of serialization/deserialization libraries is available in this article.

Websockets

Websockets are employed for real-time updates, utilizing tokio-tungstenite for websockets in the API crate. The websocket connection initiates when a user loads the game page, persisting throughout the game duration and closing when the user leaves the page or encounters a messaging error.

Two key responsibilities are:

  • Handling user in-game requests, such as starting a game or playing a card. The following code illustrates the receiving task for user messages:
let mut recv_task = {
    let shared_sender = shared_sender.clone();
    tokio::spawn(async move {
        while let Some(Ok(Message::Text(text))) = receiver.next().await {
            match handle_user_request(
                text,
                &room_code,
                &user_id,
                username.clone(),
                &redis_pool,
                &pg_pool,
            )
            .await
            {
                Ok(_) => {}
                Err(e) => {
                    let _ = shared_sender
                        .lock()
                        .await
                        .send(Message::Text(e.to_string()))
                        .await;
                    tracing::error!(
                        user_id = user_id.to_string(),
                        game_code = room_code,
                        "error handling user request due to {e}",
                        e = e.to_string()
                    );
                }
            };
        }
    })
};

This task runs continuously as long as no errors occur, capturing messages by the user. The handle_user_request function processes user requests, modifying game struct values and storing them in Redis and/or Postgres. Possible failure scenarios include bad user input, network failure, or Postgres/Redis issues.

The function returns a Result enum, containing either Ok or Err. In case of an error, I communicate the issue to the user and trace the error for observability and break out of the while loop.

To prevent race conditions during in-game scenarios, I maintain a queue in the game struct. Before starting the game, I lock the Postgres row, avoiding simultaneous conflicting requests.

honeycomb.io’s OTLP collector and visualizer have been instrumental in capturing performance insights, providing valuable data for optimization and troubleshooting. The graphs below, generated from traces collected from the live Thaas API provides additional insights into performance of the API.

Average duration for handle_user_request The average duration for the handle_user_request function is around 4-6ms, with outliers around 12ms, attributed to user join requests. These requests involve locking the Postgres table row, extending processing time.

Detailed timing breakdown for one request is provided below. Loading the game struct from Redis takes approximately 2.4ms, while handling the user request in the handle_action span consumes an additional 2.5ms. Notably, the actual game mechanics require only 0.18ms, with the remaining time allocated to loading and saving data to Redis. As Redis operations are asynchronous, the server efficiently handles other requests while awaiting responses. Spans handle_user_request

  • How do we know if another player has made a move in the game? We listen for updates in the game state stored in Redis using a feature called keyspace notifications.

However, we want to avoid excessive calls to Redis. To achieve this, we only listen for notifications once per game room. Here’s how we organize this using a shared state across the server:

pub struct AppState {
    pub rooms: RwLock<HashMap<String, Arc<RoomState>>>,
}

pub struct RoomState {
    pub terminate_flag: AtomicBool,
    pub notify: Notify,
    pub game_tx: broadcast::Sender<HashMap<Uuid, Vec<u8>>>,
}

Breaking down the code:

  • AppState: This struct acts as the manager, keeping track of all game rooms using a HashMap.
  • RoomState: This struct represents the state of a specific game room, including a terminate_flag, notify, and game_tx.
  • game_tx: This component is responsible for broadcasting updated game states to all players within a room.
  • terminate_flag: This flag is set to True when listening to Redis keyspace notifications encounter an error.
  • notify: This component is used to notify the keyspace_task to break out of the loop when the terminate_flag is set to True.

How does it work in practice?

  1. When a player makes a move, the game state is updated in Redis.
  2. Redis sends a keyspace notification to the server.
  3. The RoomState broadcasts the updated game state to all players in the room.

By using this approach, we can efficiently detect game state updates and keep everyone in sync, while minimizing calls to Redis.

impl RoomState {
    pub fn new() -> RoomState {
        RoomState {
            game_tx: broadcast::channel(10).0,
            notify: Notify::new(),
            terminate_flag: AtomicBool::new(false),
        }
    }

    pub fn start(
        self,
        room_code: String,
        redis_pool: RedisPool,
        redis_sub: SubscriberClient,
    ) -> Arc<RoomState> {
        let room_state = Arc::new(self);
        #[allow(clippy::let_underscore_future)]
        let _ = {
            // Clone things we want to pass (move) to the send task.
            let room_state = room_state.clone();
            let tx = room_state.game_tx.clone();
            // spawn a task to sync subscriptions whenever the client reconnects
            #[allow(clippy::let_underscore_future)]
            let _ = redis_sub.manage_subscriptions();
            let mut keyspace_rx = redis_sub.on_keyspace_event();
            let redis_sub_key = format!("__keyspace@0__*:{}*", room_code);

            tokio::spawn(async move {
                let _: RedisValue = redis_sub.psubscribe(redis_sub_key).await.unwrap();
                while let Ok(notification) = keyspace_rx.recv().await {
                    if notification.operation == "expired" || notification.operation == "evicted" {
                        break;
                    }

                    let status = match get_all_event_message(&room_code, &redis_pool).await {
                        Ok(user_event_messages) => {
                            tx.send(user_event_messages).context("Failed to send msg")
                        }
                        Err(e) => Err(e),
                    };
                    match status {
                        Ok(_) => {}
                        Err(e) => {
                            tracing::error!("{}", e.to_string());
                            break;
                        }
                    }
                }

                room_state.terminate_flag.store(true, Ordering::Relaxed);
                room_state.notify.notify_waiters();
            })
        };
        room_state
    }
}

The average duration for the get_all_event_message function, responsible for fetching updated game events from the Redis store, is around 2-3ms. Average duration for get_all_event_message

Spans get_all_event_message

Within the websocket handler, two distinct tasks cooperate to manage game state updates:

  1. Keyspace Task: Executes a continuous loop, awaiting notifications from RoomState task described earlier. Upon notification receipt, evaluates the terminate_flag. If true, exits the loop, indicating task completion.

  2. Send Task: Receives updates from the RoomState channel and transmits them to individual players within the room.

let mut keyspace_task = {
    tokio::spawn(async move {
        loop {
            // wait till we get notified from the keyspace listening task in room_state
            // if the terminated_flag is set to true there we break out of this loop
            // and finish this task.
            room_state.notify.notified().await;
            if room_state.terminate_flag.load(Ordering::Relaxed) {
                break;
            }
        }
    })
};

let mut send_task = {
    let shared_sender = shared_sender.clone();
    let mut rx = room_state.game_tx.subscribe();
    tokio::spawn(async move {
        while let Ok(user_events) = rx.recv().await {
            if let Some(user_event) = user_events.get(&user_id) {
                if shared_sender
                    .lock()
                    .await
                    .send(Message::Binary(user_event.clone()))
                    .await
                    .is_err()
                {
                    break;
                }
            } else {
                break;
            };
        }
    })
};

If any of send_task, recv_task or keyspace_task completes, the websocket connection is closed.

Summary:
The combined time to propagate a user action to all users, including handling the action, waiting for notifications, and fetching updated game events from Redis, averages around 10ms. The majority of this time, approximately 8.45ms, is dedicated to waiting, only 1.55ms required for the actual processing. This is sufficient, as it ensures a responsive user experience, with the delay being imperceptible during gameplay.

Game crate

To understand the inner workings of this crate, it’s essential to grasp the concept of traits in Rust. Traits serve as a means to define shared behavior in an abstract manner, akin to interfaces in other programming languages. The trait section in the Rust book provides an excellent resource for delving deeper into this concept.

Let’s focus on this abbreviated Game trait, a crucial component in this crate. This trait mandates implementation by all games (Bondi, Digu, Dhihaeh). The specific implementation of functions like new_game and restart is left to each individual game.

pub trait Game {
    type Item;

    fn new(player_username: String, num_players: usize) -> Result<Self::Item, anyhow::Error>;

    fn toggle_privacy(&mut self, player_username: String) -> Result<(), anyhow::Error>;
}

The following code illustrates how Bondi implements the Game trait. Similar structs and implementations exist for other games like Digu & Dhihaeh. As long as the game mechanics align (e.g., playing a card, drawing a card), introducing a new game is straightforward. A new game type merely needs to implement the Game trait.

This is also highlights a limitation here, the assumption that the game mechanics should be similar across different games. For this project in my view it’s fine as I don’t intend on adding more games.

#[derive(Serialize, Deserialize, Debug)]
pub struct Bondi {
    deck: Deck,
    pub active_players: usize,
    pub max_players: usize,
    pub status: GameStatus,
    pub queue: VecDeque<Uuid>,
    pub usernames: HashMap<Uuid, String>,
    pub last_round: Option<LastRound>,
    pub scores: VecDeque<Uuid>,
    pub all_players: Vec<String>,
    pub kicked_out_players: Vec<String>,
    pub host: String,
    pub privacy: GamePrivacy,
}

impl Game for Bondi {
    type Item = Bondi;
    fn new(player_username: String, num_players: usize) -> Result<Self::Item, anyhow::Error> {
        let deck = Bondi::new_blank_game(num_players, MIN_PLAYERS, MAX_PLAYERS)?;
        let bondi = Bondi {
            deck,
            active_players: 0,
            max_players: num_players,
            status: GameStatus::Pending,
            queue: VecDeque::with_capacity(num_players),
            usernames: HashMap::with_capacity(num_players),
            last_round: None,
            scores: VecDeque::with_capacity(num_players),
            all_players: Vec::new(),
            kicked_out_players: Vec::new(),
            host: player_username,
            privacy: GamePrivacy::Private,
        };
        Ok(bondi)
    }

    fn toggle_privacy(&mut self, player_username: String) -> Result<(), anyhow::Error> {
        if player_username != self.host {
            return Err(anyhow::anyhow!(
                "You are not allowed to toggle the privacy, only the host who created the game is"
            ));
        }
        self.privacy = match self.privacy {
            GamePrivacy::Private => GamePrivacy::Public,
            GamePrivacy::Public => GamePrivacy::Private,
        };
        Ok(())
    }
}

To showcase the practical application of the Game trait in the API crate, we start by defining a set of user actions encapsulated within the Action enum. Enumerations prove to be an ideal choice for this purpose, and in Rust, we are required by the compiler to handle all cases defined within the enum.

#[derive(Deserialize, Debug)]
#[serde(rename_all = "lowercase")]
enum Action {
    StartGame,
    PlayCard,
    RestartGame,
    DrawUsed,
    DrawUnused,
    TogglePrivacy,
    LeaveGame,
    KickOutPlayer,
    SetTeam,
}

On the client side, in the Next.js application, event handlers can be defined to send websocket messages specifying the desired action.

function startGame() {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ action: "startgame" }));
  }
}

In the API crate, the handle_action function is designed to manage various game-related actions, a modified version is shown below. It takes an implementation of the Game trait as its first argument, allowing it to be invoked with any struct that adheres to the trait—be it Bondi, Digu, or Dhihaeh.

fn handle_action(
    mut game: impl Game,
    action: &Action,
    parameters: Option<Value>,
    user_id: Uuid,
    username: String,
) -> Result<(), anyhow::Error> {
    match action {
        Action::StartGame => {
            game.start_game(username)?;
        }
        Action::PlayCard => {
            if let Some(value) = parameters {
                let card: Card = serde_json::from_value(value)
                    .context("Failed to parse the card value you submitted")?;
                game.play_card(card, user_id)?;
            } else {
                return Err(anyhow::anyhow!("You didn't submit any card to play"));
            }
        }
        Action::SetTeam => {
            if let Some(value) = parameters {
                let teams: HashMap<Team, Vec<String>> =
                    serde_json::from_value(value).context("Failed to parse team list")?;
                game.set_team(username, teams)?;
            } else {
                return Err(anyhow::anyhow!("You didn't submit any team info"));
            }
        }
        Action::RestartGame => {
            game.restart()?;
        }
        Action::DrawUsed => game.draw_used_card(user_id)?,
        Action::DrawUnused => game.draw_unused_card(user_id)?,
        Action::TogglePrivacy => game.toggle_privacy(username)?,
        Action::LeaveGame => game.leave_game(user_id)?,
        Action::KickOutPlayer => {
            if let Some(value) = parameters {
                let username_to_remove: String = serde_json::from_value(value)
                    .context("Failed to parse the username you submitted")?;
                game.remove_user(username, username_to_remove)?;
            } else {
                return Err(anyhow::anyhow!("You didn't submit a username to remove"));
            }
        }
    };

Next.js App

The Next.js app features a straightforward design with Shadcn for UI components and the reconnecting websockets library for efficient WebSocket management.

React Server Components proved less crucial for this project, given the WebSocket reliance. Page load times were also observed to be slower in the Maldives due to Vercel edge servers’ geographical distance, suggesting that a Single Page Application (SPA) might have been more optimal.

Despite this, the exploration of React Server Components provided valuable learning opportunities.

Conclusion

Enormous gratitude to my friends for their active involvement—playing various Thaas games, contributing to UX enhancements, brainstorming diverse ideas for the app—and their invaluable support throughout the development journey.

If you’re keen on chatting about the app, have suggestions, or just want to talk, feel free to shoot me an email at thaasapp[at]gmail[dot]com.

Thank you for taking the time to read about this project!