Estimate the scale of the system. Consider daily active users, read/write ratio, storage requirements, bandwidth, and any relevant QPS calculations...
Assuming we have 100 million active players. And each player plays on average 5 games per day.
The system should be able to handle web socket connections for 100 million online users.
For storage of player profiles, including ELO ratings, game statistics etc, for each player we will need to store 1MB of metadata, and we will need 100TB of storage.
For storage of game records each day, given 500 million new games per day, and each new game record takes 10KB, we will need 5TB of new storage per day. For games older than 1 year, we will put them in cold storage.
Define the APIs expected from the system. This is your chance to analyze and define the read and write paths so that you can come up with the high-level design...
For a user to register:
POST v1/register {
user_name: String,
password: String,
user_region: String,
user_dob: String,
user_email: Strng
}
For a user to login:
POST v1/login {
user_name: String,
user_email: String,
password: String
}
For a user to join a game:
POST v1/join_game {
user_id: UUID,
game_id: UUID,
opponent_id: UUID,
joined_at: Timestamp
}
For a user to replay a game:
GET v1/replay_game {
user_id: UUID,
game_id: UUID
}
For a user to view someone's game history:
GET v1/view_game_history {
user_id: UUID
}
For a user to view a user's user account:
GET v1/view_user_account {
user_id: UUID
}
For a user to join the queue for matchmaking:
POST v1/join_match_queue {
user_id: UUID,
}
Describe the overall system architecture. Identify the main components needed to solve the problem end-to-end. Use the diagramming tool to create a block diagram.
For requests from all the players, they go through API gateway and load balancers first. API gateway helps with rate limiting and authentications, while load balancers eventually distributes the requests to different servers.
If a user registers, after authorization, we write the user information to both the redis cache and the underlying relational database. Later, when user logins, we check the user's credentials from the cache/database against what is stored, and give user authentication for further requests. Every time a user logs in, we establish a persistent websocket connection between the user client and the server. Furthermore, we update the redis cluster on user's online/offline status whenever a user comes online/offline. Every 5 seconds, user's client sends a heartbeat to update redis cache to alert redis of the user's online/offline status. This allows us to display online/offline status of a user.
If a user plays a game, we first call the endpoint to matchmaking service. We store players actively looking for games in redis as a sorted set
Once a game starts, the game service changes the chess board status based on each player's move. It sends updates of chess board through web socket servers to each player, and document move made by each player. For each move made by a player, the game service checks the static rules stored in the code, validates and rejects invalid moves. After the game finishes, the game service triggers a kafka event, which gets consumed by game history writer, and updates the redis and relational database on the user's latest match record, statistics and elo scores. Since this happen through a message queue, there might be slight delays between user's match finishes and when the updated stats shows up. However, this is an acceptable tradeoff in favor of buffering the player game stats update from overwhelming the database.
When a player queries their profile or someone else's profile, we send request to user service, which in turn queries redis, and if not available, the relational database underneath, and build the completed profiles back. We also cache the recently played games for users in redis, in case users want to query and replay their recent games.
Define the data model. Identify the main entities, their attributes, and relationships. Consider the choice of database type (SQL vs NoSQL) and justify your decision based on access patterns...
We need to store two data types:
We can store these information in a relational database. As they are highly relational. The data integrity is enforced by relational database's ACID properties. For game analytics, we need to make complex queries which relational database supports. We won't be able to support high write throughput as easily as cassandra. But that's an acceptable tradeoff.
The user profile can easily be modeled like this:
table user_info {
user_id: UUID,
user_name: String,
user_email: String,
user_password_hashed: String,
user_geo_region: String,
user_age: Integer
}
table user_profile {
user_id: UUID,
user_name: String,
win_count: Integer,
lose_count: Integer,
user_elo_score: Double,
user_highest_rank: String,
total_games_played: Int
}
table games {
user_A_id: UUID,
user_B_id: UUID,
game_id: UUID,
game_outcome: String,
game_duration: Double,
moves_made: String
game_started_at: Timestamp
}
We can shard the user_info and user_profile by user_ids. For games table, we will shard by game_id.
Some often accessed data will be cached in the redis cluster. For example, user_id and their ELO scores will be cached, so matchmaking can easily access.
It will be a write through cache, so cache and relational database are updated at the same time when a game concludes. While this increases write latency a bit, it maintains the consistency between cache and database, and database can serve as reliable backup when cache cluster is down.
Deep dive into 2-3 key components. Explain how they work, how they scale, discuss tradeoffs, capacity, and any relevant algorithms or data structures.
We need to store two data types:
We can store these information in a relational database. As they are highly relational. The data integrity is enforced by relational database's ACID properties. For game analytics, we need to make complex queries which relational database supports. We won't be able to support high write throughput as easily as cassandra. But that's an acceptable tradeoff.
The user profile can easily be modeled like this:
table user_info {
user_id: UUID,
user_name: String,
user_email: String,
user_password_hashed: String,
user_geo_region: String,
user_age: Integer
}
table user_profile {
user_id: UUID,
user_name: String,
win_count: Integer,
lose_count: Integer,
user_elo_score: Double,
user_highest_rank: String,
total_games_played: Int
}
table games {
user_A_id: UUID,
user_B_id: UUID,
game_id: UUID,
game_outcome: String,
game_duration: Double,
moves_made: String
game_started_at: Timestamp
}
We can shard the user_info and user_profile by user_ids. For games table, we will shard by game_id.
Some often accessed data will be cached in the redis cluster. For example, user_id and their ELO scores will be cached, so matchmaking can easily access.
It will be a write through cache, so cache and relational database are updated at the same time when a game concludes. While this increases write latency a bit, it maintains the consistency between cache and database, and database can serve as reliable backup when cache cluster is down.
For requests from all the players, they go through API gateway and load balancers first. API gateway helps with rate limiting and authentications, while load balancers eventually distributes the requests to different servers.
If a user registers, after authorization, we write the user information to both the redis cache and the underlying relational database. Later, when user logins, we check the user's credentials from the cache/database against what is stored, and give user authentication for further requests. Every time a user logs in, we establish a persistent websocket connection between the user client and the server. Furthermore, we update the redis cluster on user's online/offline status whenever a user comes online/offline. Every 5 seconds, user's client sends a heartbeat to update redis cache to alert redis of the user's online/offline status. This allows us to display online/offline status of a user.
If a user plays a game, we first call the endpoint to matchmaking service. We store players actively looking for games in redis as a sorted set
Once a game starts, the game service changes the chess board status based on each player's move. It sends updates of chess board through web socket servers to each player, and document move made by each player. For each move made by a player, the game service checks the static rules stored in the code, validates and rejects invalid moves. After the game finishes, the game service triggers a kafka event, which gets consumed by game history writer, and updates the redis and relational database on the user's latest match record, statistics and elo scores. Since this happen through a message queue, there might be slight delays between user's match finishes and when the updated stats shows up. However, this is an acceptable tradeoff in favor of buffering the player game stats update from overwhelming the database.
When a player queries their profile or someone else's profile, we send request to user service, which in turn queries redis, and if not available, the relational database underneath, and build the completed profiles back. We also cache the recently played games for users in redis, in case users want to query and replay their recent games.
If 2 players happen to be on different websocket servers, we use a redis pub/sub to coordinate. Their websocket servers listen to the same redis channel, and when player A makes move, the move is broadcasted to all listeners of that redis channel, and player B would receive it through websocket connection.